Compare commits
@@ -55,7 +55,7 @@ Entries should be short imperative phrases that describe user-facing impact —
|
||||
|
||||
### Be concise — this is the rule, not a suggestion
|
||||
|
||||
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet** (enforced by `ci/changelog_gate.sh`). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
|
||||
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet**, enforced by the pre-push gate — verify before committing with `make changelog-gate` (run the `make` target, not the script it wraps). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
|
||||
|
||||
The CHANGELOG is read by humans scanning for what changed between two versions. It is **not** the place for the rationale, the probe results, the implementation detail, the behavior-change deep dive, or the "see also" cross-references. Those belong in:
|
||||
|
||||
@@ -134,6 +134,10 @@ Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags
|
||||
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
|
||||
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
|
||||
|
||||
## Don't hand-manage `.pql/changelog`
|
||||
|
||||
The pre-commit hook exports the pql ticket DB and **auto-stages `.pql/changelog/` on every commit**. Don't `git add .pql/changelog` yourself and don't write a dedicated "flush the export" commit — just make your normal commit and the hook sweeps the ticket state in. The only thing to remember: a turn that files/changes a ticket but makes **zero commits** never fires the hook, so the change won't persist (and a later branch switch can drop it). The fix is simply to make a commit — you don't need to touch `.pql/changelog`.
|
||||
|
||||
## Safety reminders (reinforced from the global Claude Code protocol)
|
||||
|
||||
- **Never** `--no-verify`. If a pre-commit hook fails, fix the underlying issue and create a new commit.
|
||||
|
||||
+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
|
||||
|
||||
+47
-2
@@ -4,9 +4,54 @@
|
||||
# Install: `make hooks` (points git core.hooksPath at .githooks/).
|
||||
# Bypass: never. If this runs slowly, fix the slow test; don't reach
|
||||
# for --no-verify (git-commit skill forbids it).
|
||||
#
|
||||
# Fast path (T-348): run the full ~2min test suite only when the push touches
|
||||
# lib/ (app + runtime Dart source) or pubspec.* (deps / version). test/,
|
||||
# assets/, docs, and tooling changes ride along with a lib change in practice,
|
||||
# and an otherwise-skipped push is covered by the next one that does touch lib.
|
||||
# The full suite is always available via `make push-check`, and the release CI
|
||||
# runs it forced on a tagged version. So a lib/pubspec-free push runs just the
|
||||
# instant decisions + changelog gates. A state we can't classify (unfetched
|
||||
# remote, new branch) runs the full gate.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "==> pre-push: make push-check"
|
||||
make push-check
|
||||
z40=0000000000000000000000000000000000000000
|
||||
|
||||
# Collect every file changed across the commits being pushed. git feeds the
|
||||
# hook one line per ref on stdin: <local-ref> <local-sha> <remote-ref> <remote-sha>.
|
||||
changed=""
|
||||
force_full=0
|
||||
while read -r _local_ref local_sha _remote_ref remote_sha; do
|
||||
[[ "$local_sha" == "$z40" ]] && continue # branch deletion — nothing to test
|
||||
if [[ "$remote_sha" == "$z40" ]]; then
|
||||
# New remote branch: diff from its merge-base with main, else play it safe.
|
||||
base="$(git merge-base "$local_sha" origin/main 2>/dev/null || true)"
|
||||
else
|
||||
base="$remote_sha"
|
||||
fi
|
||||
# If we can't resolve a base locally (e.g. the remote advanced and we haven't
|
||||
# fetched its objects), we can't classify the diff — run the full gate.
|
||||
if [[ -z "$base" ]] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
force_full=1
|
||||
break
|
||||
fi
|
||||
changed+=$'\n'"$(git diff --name-only "$base" "$local_sha")"
|
||||
done
|
||||
|
||||
# Run the full gate when lib/ (app + runtime source) or pubspec.* (deps /
|
||||
# version) is touched, or when we couldn't classify above.
|
||||
needs_gate=1
|
||||
if [[ "$force_full" -eq 0 ]]; then
|
||||
trigger_files="$(printf '%s\n' "$changed" | grep -E '^(lib/|pubspec\.)' || true)"
|
||||
[[ -z "$trigger_files" ]] && needs_gate=0
|
||||
fi
|
||||
|
||||
if [[ "$needs_gate" -eq 0 ]]; then
|
||||
echo "==> pre-push: no lib/ or pubspec change — decisions + changelog gates, skipping tests"
|
||||
make decisions-validate changelog-gate
|
||||
else
|
||||
echo "==> pre-push: make push-check"
|
||||
make push-check
|
||||
fi
|
||||
|
||||
@@ -15,3 +15,23 @@ 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 ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2ETJQP0CT6X7W3CWZ6NS9G', '2026-06-10 11:12:20', '2026-06-10 11:12:20', NULL, 'cb20764a77d2a15290ed61a186542095', 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 ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-10 11:12:25', '2026-06-10 11:12:25', NULL, 'dd9434426790edcaa556221f74c431ba', 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 ('06FB2ERREMEEF26KKHGNZBWW64', '06FB2G1WD1839Z90AQ5C0BHNV4', '2026-06-10 11:17:25', '2026-06-10 11:17:25', NULL, '5535e3d16bee2a2cdfcbeb84ea3fca99', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', '06FB2TY91VHK7TPKPMZ11EG3TM', '2026-06-10 12:04:53', '2026-06-10 12:04:53', NULL, 'ed3717d8f6467c0a77236eda670efce5', 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 ('06FB0TNQM5TWC00GW0P3X02HZW', '06FB2TY91VHK7TPKPMZ11EG3TM', '2026-06-10 12:04:53', '2026-06-10 12:05:01', '2026-06-10 12:05:01', '137cd047c9eaec01cac955b49fedd839', 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', '06FB3DKQQJ583944DG8561VQ3G', '2026-06-10 13:27:04', '2026-06-10 13:27:04', NULL, '73383d1011fbad641395f27271b141e6', 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 ('06FB3DKQQJ583944DG8561VQ3G', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-10 13:27:04', '2026-06-10 13:27:04', NULL, '45533aa2b124e6547a3804294fcf3aaf', 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-10 13:27:05', NULL, '0eb2faf91e36a4d37a8a3aa97a8edb71', 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 ('06FB3DKQQJ583944DG8561VQ3G', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-10 13:27:05', NULL, 'b329ff8925097a977f528e7e114d6055', 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', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:08', '2026-06-10 13:27:08', NULL, 'f494f6efad2377e6fc6d0bbf7ae35f07', 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-10 13:27:09', NULL, 'e5d02fc3a99106d29a9ce6af0626f68e', 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', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-12 01:11:12', NULL, '17f1c884268a172f803f407a2ad47c8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-12 01:11:17', NULL, 'a67368ff15089dce57838840632fe07e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-12 01:11:22', NULL, '0ec8ff6c455136e45fbb1d06a2a690f1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-12 01:11:26', NULL, '3c985828c591c88d492eb261c6d26d33', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-12 01:11:31', '2026-06-12 01:11:31', NULL, '81fd318a43138a3bef82e31f928ff0b2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKMXSVCE98K1H76N00TYCQR', '2026-06-12 03:16:35', '2026-06-12 03:16:35', NULL, '28d9785a1f9696b6b579a1dbb9fdb889', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -149,3 +149,86 @@ 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 ('06FB2G1WD1839Z90AQ5C0BHNV4', 'T-322', '2026-06-10 11:17:17', '2026-06-10 11:17:17', NULL, 'f33b1db4b67a0521f02c5c49fcfa8e6f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'T-323', '2026-06-10 11:17:23', '2026-06-10 11:17:23', NULL, '0738a8da475a6d8d49132d1e4e753775', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'T-324', '2026-06-10 11:26:07', '2026-06-10 11:26:07', NULL, 'd8aa9ae4522d97b70331c42b8ab7a365', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'T-325', '2026-06-10 12:00:52', '2026-06-10 12:00:52', NULL, '47b8f31d9337c5bbc6476d62c7f6ed4d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'T-326', '2026-06-10 12:04:51', '2026-06-10 12:04:51', NULL, '348dee79d9d3fe0a8b260c1f9c47a289', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'T-327', '2026-06-10 12:10:04', '2026-06-10 12:10:04', NULL, '967aab6bc692754a5d3a192cb0008871', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'T-328', '2026-06-10 13:00:06', '2026-06-10 13:00:06', NULL, '553b6683bbcddfbbab39ee545a99c98c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DHCTP001YCHFP39XER0ZM', 'T-329', '2026-06-10 13:26:06', '2026-06-10 13:26:06', NULL, 'a7b63cf73534de82c953d3029251b5e4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', 'T-330', '2026-06-10 13:26:19', '2026-06-10 13:26:19', NULL, 'c7ef552dc6d7fc2a5e312b20939a2987', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DKQQJ583944DG8561VQ3G', 'T-331', '2026-06-10 13:26:25', '2026-06-10 13:26:25', NULL, '002e4d81284119aecab05d180a9a41cd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', 'T-332', '2026-06-10 13:26:31', '2026-06-10 13:26:31', NULL, '38d661bbd4a937daf395f82074cf8d12', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DN94MBCTYJW17ZCYVSXE0', 'T-333', '2026-06-10 13:26:38', '2026-06-10 13:26:38', NULL, 'c710c528662f92c6b7416b7163c8e99f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DNQZKV20F7YG5PJH8V8SM', 'T-334', '2026-06-10 13:26:42', '2026-06-10 13:26:42', NULL, '7f774dc51ae344eddfd42c2149887d56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DP48FS33CQGRDF7EB9GT0', 'T-335', '2026-06-10 13:26:45', '2026-06-10 13:26:45', NULL, '1cce32ce77997202bf74c69426c29ab9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', 'T-336', '2026-06-10 13:26:56', '2026-06-10 13:26:56', NULL, 'b988abcd2cef0dbd6adbc56502f676ca', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'T-337', '2026-06-10 13:27:36', '2026-06-10 13:27:36', NULL, '94b0ebd244a6794e0e4e5a868b38d67f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'T-338', '2026-06-10 13:47:04', '2026-06-10 13:47:04', NULL, 'be9b536d471a6008cc854092dfa6aaa4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'T-339', '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, 'e76a2ce5d3149bb7ca0ec8a45f5c57a2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'T-340', '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'bdd164a73576f6ca5151c8a62640526f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'T-341', '2026-06-10 15:07:43', '2026-06-10 15:07:43', NULL, '113520f8f3a640fa2f470200ea9f45d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'T-342', '2026-06-10 15:26:33', '2026-06-10 15:26:33', NULL, '13a646741953f113356c4bcda162c3c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'T-343', '2026-06-10 15:54:09', '2026-06-10 15:54:09', NULL, 'e48d2b913bb03202409c6568d9bc11a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'T-344', '2026-06-10 16:06:08', '2026-06-10 16:06:08', NULL, '481e82dfab98c20cd37c77a3e0c16668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'T-345', '2026-06-10 16:33:23', '2026-06-10 16:33:23', NULL, '9f26d5036c05057d4bd0fc53ddaddafd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4VG3N3YJSV8G7M1HFSYW2W', 'T-346', '2026-06-10 16:46:54', '2026-06-10 16:46:54', NULL, 'a64940cb0b4ec4fb489eac27065dc447', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'T-347', '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '9d40226dbc6136072d2d5d0eda71f141', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'T-348', '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, '8bb5ad92551f272417840869a0774668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'T-349', '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, '0b12f14fa83254ab113c870531628359', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'T-354', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '32bb5d359401599022a8771031d8a08a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'T-355', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, '2eb6809e958613a924b35e080ab17609', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', 'T-356', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'bdb597080218a3e8783f6c5cf74c529a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'T-357', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '96cf88e036dc3a45487cdbffff26cdde', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'T-358', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '2a656aaec54bf50c34c7e28347b1fb29', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBGHNEQTAEPGNJKN42C1E8', 'T-359', '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, '1930fad7b18c79cf97d913d8372b77bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'T-360', '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '1ffef3c00cdca28566ab67a11ec17e53', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBKK2TZQK683J8FS0ZH5A4', 'T-361', '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '5f8bdede98496496bf031a40a09dee16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'T-362', '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, '610e74eb7b4ff9db3949ed01a0268380', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBPQE4J4YBJX92812ZK6DR', 'T-363', '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, '5daf7bcd9ccdc37f8aed531ef12d395f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'T-364', '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '025de46ba9e8d005fd8b1f74687cd8b6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'T-365', '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, 'b366b41e4737a2761a01284fd7dd44e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'T-366', '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, '8a75dc7dea83a0e643d1912bda46dd57', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBWE2W1226T58CX37E50HC', 'T-367', '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '578b51eafd19044dc0e2720f6e55d633', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'T-368', '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '0df409c83fe28af2d49a156118ed6ece', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'T-369', '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '7f47ddc3e84f9fea915200992a5a0baf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'T-370', '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, '1908b7c0903c389ad5b743fb89ca32e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'T-371', '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, '2095f10159561a5e81b2a9b990c79fa2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'T-372', '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '9d22f370e726af56d47d6c8acd93bc87', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC7KDFW07S8WTCC3MD71J0', 'T-373', '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '231c399183a83e569c0645e1d149625f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'T-374', '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, '9832ac0b3e0bebd85f3271a5ea96d4ed', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'T-375', '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, 'de3569d098b5c4cf6a3d49ee3d33d1c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCC6AR37VTF4SY8DR99JHC', 'T-376', '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, 'fe23c94d8971d21963a2b2e2739e05a3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCEC25337J2AXXQNST56Y4', 'T-377', '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '1585df7f3826ddbcef8a030ffd1e0890', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCG84F4SPFW111CC4K26A8', 'T-378', '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'c6b0d9d124401f1d2bdc403567cbfdf8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCJEYHC91PMVNVWVHBR2RG', 'T-379', '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '0d9aed402c9840ef6fb75edfcfcbc3f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCM1RBAF72SCZBKRTXJSYC', 'T-380', '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, '1a0d767f30c9bc0e9c96f39d468ca67b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'T-381', '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'cfac236e079164f9588d936f5101c71c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'T-382', '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'b54be44ec4cfbbc649324471a5e2141f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCST6CQ449VJGAP6C5KZ5W', 'T-383', '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '0e18de4e94e4fcf36fc40de763522cbd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'T-384', '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '57d7b6a6c05791acadd1cebe611c8991', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'T-385', '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, 'ebda2dd9c7dfe92bab2260dc34212ac4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD098CV2N73823KX4Z99P4', 'T-386', '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, 'de90501f2d4be80231651d25d04f4649', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD2FWTDFYA00W4QXTE41M0', 'T-387', '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '8a247364a872c223b37682c6496d3920', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'T-388', '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'cc238ee850d4d0ed90d05b9a42c8d506', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD6FQMXMQQQ877KMNCK6QC', 'T-389', '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, 'bb0657e304f4ad1445d756ef7170c62d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHD8F2NBEFZNPKSJ9W253J0', 'T-390', '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '35453f0d8c7e0fb89c243481f870de03', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'T-391', '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '0647245b4da95532bb3abd6f20cd87de', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDC7B2MFQZVR1K9E30FXDR', 'T-392', '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, 'a1612ff75df3d134b8e05c716d24ebfe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'T-393', '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, 'f8f29fb8b31cc8afdd8af989c3f711f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'T-394', '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'f9cee5dc96cedfabc3eaaf7352e732c8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'T-395', '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, '382a5742e32da0f38c1e143715a0b656', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'T-396', '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '20577481e56f82bb0df9e56a266303c9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'T-397', '2026-06-12 00:52:25', '2026-06-12 00:52:25', NULL, 'bf89e25b384be60faa65e9eb1ec2fab9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'T-398', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, 'd0ed46ee279c148f1d76683e86e0d4aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', 'T-399', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, 'eacf3522aeb2bccbaf006c9703b9794d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'T-400', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'a7971f428145d04ae82d4cd89f3eeb9d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN2MP35NPPK1BRDYY2M428', 'T-401', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '29980034db5fe381473b156ece7d8a1a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKN4QVFVE51MY2N0CWCVXHM', 'T-402', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, 'c65717bf20ea6886ef7a86f5cb4b2929', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'T-403', '2026-06-12 03:20:52', '2026-06-12 03:20:52', NULL, '372a686b214820b5d093b11b9f3d57a5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'T-404', '2026-06-12 03:21:11', '2026-06-12 03:21:11', NULL, 'c479a9dea6687b553bc638a9849cc5de', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+315
@@ -16,6 +16,321 @@ 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
|
||||
|
||||
- **Ticket/decision sidebars load on first open after a desktop launch.** A
|
||||
desktop launch starts in HOME (not a git repo), and the daemon booted its
|
||||
pql/git/files workspace there — so pql ran in HOME, hit a stale
|
||||
`~/.pql/pql.db`, and the sidebars showed "pql … failed" until the project was
|
||||
reopened (a manual refresh worked once the workspace had swapped to the repo).
|
||||
The daemon now boots at the last opened project when the launch directory
|
||||
isn't itself a repo, so pql targets the real workspace from the first request.
|
||||
(T-352)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Minimum toolchain raised to honest values.** `pubspec.yaml` now declares
|
||||
Flutter `>=3.35.0` / Dart `>=3.9.0` (was `3.19.0` / `3.5.0`) — the real
|
||||
minimums our deps already required (`alchemist` needs Flutter 3.32; Dart 3.9
|
||||
ships in Flutter 3.35). The exact build toolchain is pinned in `.fvmrc`
|
||||
(Flutter 3.44.1). Moving to the Dart 3.9 language level reformatted the tree
|
||||
to the new "tall" style and adopted two new lints (`unnecessary_underscores`,
|
||||
`use_null_aware_elements`). (T-353)
|
||||
|
||||
### Security
|
||||
|
||||
- **Dependency audit + refresh.** Reviewed every pinned and transitive
|
||||
dependency against the GitHub Advisory Database / OSV (Pub ecosystem) — no
|
||||
advisory affects any current or candidate version. Refreshed the safe pins:
|
||||
`ffi` 2.1.3→2.2.0, `jovial_svg` 1.1.26→1.1.30 (pulls `jovial_misc` 0.10.0 +
|
||||
`xml` 7.0.1), `mocktail` 1.0.4→1.0.5, and `markdown` 7.2.2→7.3.1 (unblocked by
|
||||
the Dart 3.9 floor below). Deliberately held with reasons in `pubspec.yaml`:
|
||||
`alchemist` (0.13 golden churn), `test` (Flutter-SDK locked). (T-353)
|
||||
- **Supply-chain gate (`make security`).** A new `security` target runs
|
||||
`osv-scanner` over `pubspec.lock` and fails if any resolved dependency has a
|
||||
known advisory — a hard gate on top of `dart pub`'s passive, non-failing
|
||||
advisory print. Intended for the CI PR-merge pipeline (kept out of
|
||||
`push-check` so dev machines don't need the scanner installed); run locally
|
||||
any time with `make security`. (T-353)
|
||||
|
||||
## [2.3.2] — 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ticket and decision sidebars reliably load on first open (real fix).** The
|
||||
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
|
||||
is up; with sticky-startup the project opens during boot, before the panes
|
||||
mount, so they never saw the event. The underlying cause was a race: the boot
|
||||
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
|
||||
ran concurrently, and the late-finishing boot swap could clobber the repo
|
||||
bind — leaving the daemon's pql/git/files pointed at the launch directory
|
||||
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
|
||||
serialized so the repo bind always wins. (T-352)
|
||||
|
||||
## [2.3.1] — 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Frameless window chrome works on KDE Plasma 6 / KWin 6.** The Wayland
|
||||
server-decoration request fired on `realize`, before GTK created the
|
||||
surface, so it bailed and KWin (which defaults to server-side decorations)
|
||||
kept drawing its own title bar. It now also fires on `map`. (T-351)
|
||||
- **pql sidebar panes no longer stick on a transient startup error.** A
|
||||
too-early or db-busy pql failure (the planning DB still settling, or a
|
||||
SQLite lock under concurrent writes) is now retried a few times before
|
||||
surfacing, instead of leaving the pane on "pql … failed" until a manual
|
||||
refresh. (T-350)
|
||||
- **Ticket and decision sidebars load on first open, not just after a manual
|
||||
refresh.** On a desktop launch the daemon's pql workspace starts as the
|
||||
launch directory, not the repo, so the panes' first fetch ran against the
|
||||
wrong (or a stale-schema) DB and errored. They now re-fetch when the
|
||||
workspace actually opens, by which point the pql workspace is the repo. (T-352)
|
||||
|
||||
## [2.3.0] — 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tools like `pql` resolve when clide is launched from the desktop on Linux.**
|
||||
A desktop launch inherits a minimal PATH without `~/.local/bin`, so the pql
|
||||
pane (and other PATH-resolved tools) failed — the PATH expansion that fixes
|
||||
this previously ran on macOS only. It now also runs on Linux. (T-347)
|
||||
- **Consistent card font sizes in the Claude conversation.** Tool/result cards
|
||||
and the Activity/run collapser cards now share the same header-label (14) and
|
||||
collapsed-summary (13) sizes, so neighbouring cards in the stream no longer
|
||||
render 1–2px apart. (T-344)
|
||||
- **"Deny & simplify" no longer shows a loud red error.** A denial the user
|
||||
deliberately chose (Deny & simplify) folds into a muted, collapsed "denied"
|
||||
card instead of the prominent expanded-red block reserved for genuine tool
|
||||
failures — which still render expanded. Driven by a reusable per-result
|
||||
"quiet error" flag, not by matching the note text. (T-340)
|
||||
- **Sub-agent prompts no longer render as a blue "you" card.** In live
|
||||
(stream-json) sessions the spawning prompt is tagged with `parent_tool_use_id`,
|
||||
not the transcript's `isSidechain`/`parentUuid`, so it slipped past the
|
||||
sidechain fold. The parser now treats that field as a sidechain marker and
|
||||
folds the prompt into its Agent card. (T-338)
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-type filter chips on the tickets panel.** A row of toggle chips
|
||||
(Initiative · Epic · Story · Task · Bug, large→small) below the filter box.
|
||||
Click a chip to toggle that type; double-click to isolate it (chart-legend
|
||||
solo); disabling the last one snaps all back on. ANDed with the text filter.
|
||||
All on by default. (T-343)
|
||||
- **VS Code keybinding preset.** A `vscode` keymap mapping VS Code's default
|
||||
shortcuts (Ctrl+P, Ctrl+Shift+P, Ctrl+B, Ctrl+J, Ctrl+`, zoom, …) to clide.
|
||||
Activate via the "Keymap: VS Code" command or `app.keymap.preset = vscode`. (T-64)
|
||||
- **JetBrains keybinding preset.** A `jetbrains` keymap mapping IntelliJ's
|
||||
defaults (Find Action, Go to File, tool windows, …). Double-Shift "Search
|
||||
Everywhere" isn't expressible by the chord matcher yet (T-341), so Go to File
|
||||
stands in for quick-open. (T-66)
|
||||
- **Picking up a ticket now starts it.** Handing a ticket to a live Claude pane
|
||||
(sidebar pick-up) also moves it to `in_progress` and refreshes the sidebar —
|
||||
but only on acceptance and only from a not-yet-started status, so a pick-up
|
||||
with no live pane is a quiet no-op and a re-pick-up never moves a ticket
|
||||
backwards. (T-339)
|
||||
- **Clickable file references in the Claude conversation.** Workspace file paths
|
||||
mentioned by Claude — bare (`lib/app.dart`), with a line (`lib/app.dart:42`),
|
||||
backticked, or as markdown links — are now clickable and open in the editor,
|
||||
jumping to the line when present. Only paths that actually exist in the repo
|
||||
linkify, so prose like version numbers stays literal. (T-300)
|
||||
- **Hand a ticket to Claude from the sidebar.** Hovering a ticket card reveals a
|
||||
run icon; clicking it hands the full ticket to the active Claude pane as a
|
||||
"pick this up and start" prompt. Routed over the message bus, so the sidebar
|
||||
stays decoupled from the session internals. (T-327)
|
||||
- **Claude's task list is now visible, docked above the composer.** When Claude
|
||||
is tracking a TodoWrite checklist, a compact display-only strip shows it pinned
|
||||
above the input — collapsed to `N tasks · M done` + the current in-progress
|
||||
item, expandable to the full list with per-item status glyphs. Hidden when
|
||||
there are no tasks. (T-308)
|
||||
- **A "Deny & simplify" option on the permission card.** A fourth button
|
||||
(alongside Allow / Allow-and-remember / Deny) denies the action with a
|
||||
preformatted note telling Claude it was too complex and to retry simpler —
|
||||
without writing a memory or changing settings. A typed note is appended;
|
||||
addressable by number key (4, or 3 without remember). (T-311)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Clicking outside the image in the lightbox now closes it.** Previously only
|
||||
the thin margin dismissed — a click on the dimmed canvas beside a letterboxed
|
||||
image hit the viewer and did nothing. A single tap outside the painted image
|
||||
now closes it (matching Esc / the × button); tapping, dragging, or zooming the
|
||||
image still doesn't. (T-309)
|
||||
- **Run-status indicators no longer crash on rapid flips.** Switching status
|
||||
back and forth within the 200ms cross-fade (e.g. running → success → running
|
||||
across two bound Claude panes) tripped an AnimatedSwitcher duplicate-key
|
||||
assertion and a cascade of follow-on errors. Each glyph now carries a key
|
||||
unique per change, so an exiting and entering glyph never collide. (T-326)
|
||||
- **The activity-card run-status spinner is now legible.** At 12px the spinning
|
||||
logo mark read as a static speck; the run-status indicator on collapsible
|
||||
cards is bumped to a `clideIconHero` (26) so the running state is clear at a
|
||||
glance. The check / cross share the size, so the card doesn't jump on settle.
|
||||
(T-304)
|
||||
|
||||
## [2.2.0] — 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -76,10 +76,21 @@ make clean # remove build artefacts
|
||||
|
||||
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
|
||||
|
||||
### Tooling discipline
|
||||
|
||||
The `make` targets above are the entry points — run them, not the scripts they wrap. Check the changelog with `make changelog-gate`, never `ci/changelog_gate.sh` directly; same for `analyze`/`format`/`test`/`push-check`. The `make` layer sets up the environment and stays correct if a script moves.
|
||||
|
||||
Shell hygiene (keeps commands inside the permission allowlist, so they don't get denied mid-task):
|
||||
- **Working directory is the repo root already** — don't prepend `cd /…/clide` or pass `git -C`. Just run the command.
|
||||
- **One command per invocation** — no `&&`/`;` chaining and no multiple greps/echos in one call. The only exception is the `git commit -F` HEREDOC.
|
||||
- Prefer the Read/Edit/Grep tools over `cat`/`sed`/`grep` for inspecting files.
|
||||
|
||||
## Git workflow
|
||||
|
||||
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
||||
|
||||
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
|
||||
|
||||
## Changelog discipline
|
||||
|
||||
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `pubspec.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
|
||||
|
||||
@@ -309,8 +309,8 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
|
||||
# -- security -------------------------------------------------------------
|
||||
|
||||
.PHONY: security
|
||||
security: ## Dart advisory review.
|
||||
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
|
||||
security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
|
||||
ci/osv_scan.sh
|
||||
|
||||
# -- pre-push gate --------------------------------------------------------
|
||||
|
||||
@@ -319,6 +319,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
|
||||
pql decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
# NOTE: the `security` (osv-scanner) gate is deliberately NOT in push-check —
|
||||
# it runs in the CI PR-merge pipeline (where the scanner is provisioned) so we
|
||||
# don't force every dev machine to install osv-scanner. Run it locally any time
|
||||
# with `make security`.
|
||||
push-check: decisions-validate changelog-gate test-coverage coverage-gate test-core ## Pre-push gate (fast — <2 min target). Order is fail-fast: instant gates (decisions, changelog) first, then the coverage suite + gate (the expensive, most-likely-to-fail stage) BEFORE test-core — a coverage miss aborts here instead of after running everything, so a fix doesn't force a full re-run of the rest. test-coverage already runs the a11y suite (test/a11y), so no separate test-a11y pass.
|
||||
|
||||
.PHONY: push-check-full
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# clide JetBrains / IntelliJ keymap preset (T-66).
|
||||
#
|
||||
# Maps IntelliJ's default keybindings to clide intents + commands. A preset
|
||||
# fully REPLACES the active layer, so this file is self-contained.
|
||||
#
|
||||
# Notation (D-82): `+` joins a chord, a space sequences, a YAML list
|
||||
# alternates. IntelliJ's mac and Linux/Windows defaults diverge for several
|
||||
# actions (Go to File is Cmd+Shift+O on mac but Ctrl+Shift+N on win/linux),
|
||||
# so both spellings are bound.
|
||||
#
|
||||
# Scope flags: only `palette.open` / `quickOpen.open` are published today;
|
||||
# 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).
|
||||
|
||||
name: jetbrains
|
||||
|
||||
bindings:
|
||||
# -- Activation / focus -----------------------------------------------
|
||||
# No F6 panel cycling here: IntelliJ uses F6 (Move) / Shift+F6 (Rename),
|
||||
# so binding them to panel focus would fight muscle memory.
|
||||
- intent: activate
|
||||
keys: [enter, space]
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
- intent: focus.next
|
||||
keys: tab
|
||||
- intent: focus.previous
|
||||
keys: shift+tab
|
||||
|
||||
# -- Find Action (≈ command palette): Ctrl+Shift+A --------------------
|
||||
- intent: palette.open
|
||||
keys: [ctrl+shift+a, meta+shift+a]
|
||||
- intent: palette.selectNext
|
||||
keys: down
|
||||
when: palette.open
|
||||
- intent: palette.selectPrevious
|
||||
keys: up
|
||||
when: palette.open
|
||||
- intent: palette.accept
|
||||
keys: enter
|
||||
when: palette.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: palette.open
|
||||
|
||||
# -- 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, shift shift]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: down
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.selectPrevious
|
||||
keys: up
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.accept
|
||||
keys: enter
|
||||
when: quickOpen.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: quickOpen.open
|
||||
|
||||
# -- Find in Path (search): Ctrl+Shift+F ------------------------------
|
||||
- intent: findInFiles.open
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
|
||||
# -- Tool windows -----------------------------------------------------
|
||||
# Project (Alt+1) → sidebar; Terminal (Alt+F12) → bottom dock; Hide All
|
||||
# Windows / distraction-free (Ctrl+Shift+F12) → focus (zen) mode.
|
||||
- intent: command:sidebar.collapse
|
||||
keys: [alt+1, meta+1]
|
||||
- intent: command:dock.toggle
|
||||
keys: alt+f12
|
||||
- intent: command:panel.focusMode
|
||||
keys: [ctrl+shift+f12, meta+shift+f12]
|
||||
|
||||
# -- Editor -----------------------------------------------------------
|
||||
# Close active tab: Ctrl+F4 (win/linux) / Cmd+W (mac).
|
||||
- intent: command:editor.close
|
||||
keys: [ctrl+f4, meta+w]
|
||||
# Zoom is a clide convenience — IntelliJ has no default zoom keys.
|
||||
- intent: command:view.zoomIn
|
||||
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||
- intent: command:view.zoomOut
|
||||
keys: [ctrl+minus, meta+minus]
|
||||
- intent: command:view.zoomReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# clide VS Code keymap preset (T-64).
|
||||
#
|
||||
# Maps VS Code's default keybindings to clide intents + commands so users
|
||||
# with VS Code muscle memory feel at home. A preset fully REPLACES the
|
||||
# active layer, so this file is self-contained (activation, focus, palette
|
||||
# and quick-open navigation are all repeated from `default.yaml`).
|
||||
#
|
||||
# Notation (D-82): `+` joins a chord, a space sequences (`ctrl+k z`), a
|
||||
# YAML list alternates (`[ctrl+b, meta+b]`). Both Ctrl (Linux/Windows) and
|
||||
# Meta/Cmd (macOS) variants are bound so one preset serves every platform.
|
||||
#
|
||||
# Scope flags: only `palette.open` and `quickOpen.open` are published today
|
||||
# (by the palette / quick-open overlays). VS Code's editor-scoped contexts
|
||||
# (`editor.focused`, `inputFocused`) have no producer yet, so the global
|
||||
# chords below stay ungated — which matches VS Code, where these commands
|
||||
# (palette, quick-open, sidebar toggle, …) are global anyway. Editor-text-
|
||||
# scoped gating lands when those scope producers do.
|
||||
|
||||
name: vscode
|
||||
|
||||
bindings:
|
||||
# -- Activation / focus -----------------------------------------------
|
||||
- intent: activate
|
||||
keys: [enter, space]
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
- intent: focus.next
|
||||
keys: tab
|
||||
- intent: focus.previous
|
||||
keys: shift+tab
|
||||
# Cycle the editor "parts" / panels (VS Code F6).
|
||||
- intent: focus.nextPanel
|
||||
keys: f6
|
||||
- intent: focus.previousPanel
|
||||
keys: shift+f6
|
||||
|
||||
# -- Command palette (Ctrl+Shift+P, F1) -------------------------------
|
||||
- intent: palette.open
|
||||
keys: [ctrl+shift+p, meta+shift+p, f1]
|
||||
- intent: palette.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
when: palette.open
|
||||
- intent: palette.selectPrevious
|
||||
keys: [up, ctrl+p]
|
||||
when: palette.open
|
||||
- intent: palette.accept
|
||||
keys: enter
|
||||
when: palette.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
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, shift shift]
|
||||
when: "!palette.open"
|
||||
- intent: quickOpen.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.selectPrevious
|
||||
keys: up
|
||||
when: quickOpen.open
|
||||
- intent: quickOpen.accept
|
||||
keys: enter
|
||||
when: quickOpen.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: quickOpen.open
|
||||
|
||||
# -- Search (Ctrl+Shift+F) --------------------------------------------
|
||||
- intent: findInFiles.open
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
|
||||
# -- Panel toggles ----------------------------------------------------
|
||||
# Toggle Side Bar (Ctrl+B), Panel (Ctrl+J), Secondary Side Bar
|
||||
# (Ctrl+Alt+B). clide's left sidebar / bottom dock / right context bar.
|
||||
- intent: command:sidebar.collapse
|
||||
keys: [ctrl+b, meta+b]
|
||||
- intent: command:dock.toggle
|
||||
keys: [ctrl+j, meta+j]
|
||||
- intent: command:context.collapse
|
||||
keys: [ctrl+alt+b, meta+alt+b]
|
||||
# Toggle Terminal (Ctrl+`). clide has one bottom dock where shell/output
|
||||
# live, so the terminal chord targets the same dock as Ctrl+J.
|
||||
- intent: command:dock.toggle
|
||||
keys: [ctrl+backquote, meta+backquote]
|
||||
# Zen Mode (Ctrl+K Z).
|
||||
- intent: command:panel.focusMode
|
||||
keys: ["ctrl+k z", "meta+k z"]
|
||||
|
||||
# -- Editor actions ---------------------------------------------------
|
||||
# Close Editor (Ctrl+W).
|
||||
- intent: command:editor.close
|
||||
keys: [ctrl+w, meta+w]
|
||||
# Window zoom (VS Code View: Zoom In/Out/Reset). `+` is shift+equal on
|
||||
# most layouts, so bind both equal and shift+equal.
|
||||
- intent: command:view.zoomIn
|
||||
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||
- intent: command:view.zoomOut
|
||||
keys: [ctrl+minus, meta+minus]
|
||||
- intent: command:view.zoomReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
|
||||
# -- File / workspace -------------------------------------------------
|
||||
# Open Folder (Ctrl+K Ctrl+O), New Window (Ctrl+Shift+N), Close Folder
|
||||
# (Ctrl+K F), Color Theme (Ctrl+K Ctrl+T).
|
||||
- intent: command:file.openFolder
|
||||
keys: ["ctrl+k ctrl+o", "meta+k meta+o"]
|
||||
- intent: command:file.newWindow
|
||||
keys: [ctrl+shift+n, meta+shift+n]
|
||||
- intent: command:file.closeWorkspace
|
||||
keys: ["ctrl+k f", "meta+k f"]
|
||||
- intent: command:theme.pick
|
||||
keys: ["ctrl+k ctrl+t", "meta+k meta+t"]
|
||||
+6
-15
@@ -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.1.0"
|
||||
version: "2.4.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
@@ -116,7 +116,7 @@ dependencies:
|
||||
|
||||
- name: ffi
|
||||
kind: dart-package
|
||||
version: "2.1.3"
|
||||
version: "2.2.0"
|
||||
homepage: https://pub.dev/packages/ffi
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -144,7 +144,7 @@ dependencies:
|
||||
purpose: >-
|
||||
Incremental parsing library with embedded WASM grammar engine.
|
||||
Vendored as libtree-sitter.so (wasmtime statically linked) in
|
||||
app/native/. Called via dart:ffi. Loads grammar .wasm files
|
||||
native/linux-x64/. Called via dart:ffi. Loads grammar .wasm files
|
||||
through its built-in WASM store API.
|
||||
|
||||
- name: wasmtime
|
||||
@@ -171,7 +171,7 @@ dependencies:
|
||||
|
||||
- name: jovial_svg
|
||||
kind: dart-package
|
||||
version: "1.1.26"
|
||||
version: "1.1.30"
|
||||
homepage: https://pub.dev/packages/jovial_svg
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -182,7 +182,7 @@ dependencies:
|
||||
|
||||
- name: markdown
|
||||
kind: dart-package
|
||||
version: "7.2.2"
|
||||
version: "7.3.1"
|
||||
homepage: https://pub.dev/packages/markdown
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -205,15 +205,6 @@ dependencies:
|
||||
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||
# here for audit completeness; NOT rendered in the About screen.
|
||||
dev_dependencies:
|
||||
- name: mocktail
|
||||
kind: dart-package
|
||||
version: "1.0.4"
|
||||
homepage: https://pub.dev/packages/mocktail
|
||||
license: MIT
|
||||
purpose: >-
|
||||
Mocks at IO / IPC boundaries. ChangeNotifier facades use
|
||||
hand-rolled fakes instead of mocks (D-025).
|
||||
|
||||
- name: alchemist
|
||||
kind: dart-package
|
||||
version: "0.12.1"
|
||||
@@ -232,7 +223,7 @@ dev_dependencies:
|
||||
|
||||
- name: test
|
||||
kind: dart-package
|
||||
version: "1.30.0"
|
||||
version: "1.31.0"
|
||||
homepage: https://pub.dev/packages/test
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Supply-chain gate — fails the push if any resolved dependency in
|
||||
# pubspec.lock has a known advisory (OSV / GitHub Advisory Database).
|
||||
#
|
||||
# Complements `dart pub get`'s passive advisory print (informational,
|
||||
# non-failing) with a hard, fail-closed gate. Native deps (dugite,
|
||||
# tree-sitter, wasmtime) are vendored by SHA and not in a lockfile OSV
|
||||
# reads — they're reviewed separately on bump (D-42, CLAUDE.md supply chain).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Resolve osv-scanner: PATH first, then a brew prefix (the git pre-push hook
|
||||
# may run with a leaner PATH than the dev's interactive shell).
|
||||
OSV="$(command -v osv-scanner || true)"
|
||||
if [[ -z "$OSV" ]] && command -v brew >/dev/null 2>&1; then
|
||||
cand="$(brew --prefix 2>/dev/null)/bin/osv-scanner"
|
||||
[[ -x "$cand" ]] && OSV="$cand"
|
||||
fi
|
||||
if [[ -z "$OSV" ]]; then
|
||||
echo "==> osv gate: osv-scanner not found on PATH." >&2
|
||||
echo " Install it: brew install osv-scanner" >&2
|
||||
echo " (or: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "==> osv gate: scanning pubspec.lock for known advisories"
|
||||
if "$OSV" scan source --lockfile=pubspec.lock; then
|
||||
echo "==> osv gate OK: no known advisories"
|
||||
else
|
||||
echo "==> osv gate FAIL: a dependency has a known advisory (see above)." >&2
|
||||
echo " Bump the affected package (+ its assets/licenses.yaml entry), or" >&2
|
||||
echo " document an explicit, justified exception before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
+14
-6
@@ -41,15 +41,23 @@ dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart t
|
||||
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
|
||||
# pass below). See dart_test.yaml + T-193.
|
||||
if [[ "$coverage" == 1 ]]; then
|
||||
# Each pass writes its raw coverage into a per-run temp dir (via
|
||||
# --coverage-path), never the shared coverage/lcov.info / lcov.parallel.info.
|
||||
# So a concurrent `flutter test --coverage` — a second push gate, or a
|
||||
# `make test` during a push — can't race or delete this run's intermediates
|
||||
# (which crashed merge_lcov with FileNotFoundError). Only the final merged
|
||||
# result lands in coverage/lcov.info, via an atomic rename within coverage/.
|
||||
# (T-345)
|
||||
COV_TMP="$(mktemp -d "${TMPDIR:-/tmp}/clide-cov.XXXXXX")"
|
||||
trap 'rm -rf "$COV_TMP" "coverage/.lcov.$$.info"' EXIT
|
||||
echo "==> flutter test --coverage (parallel pool; excludes pty + serial)"
|
||||
flutter test -r "$REPORTER" --coverage --exclude-tags "pty || serial" --timeout 60s
|
||||
cp coverage/lcov.info coverage/lcov.parallel.info
|
||||
flutter test -r "$REPORTER" --coverage --coverage-path "$COV_TMP/parallel.info" --exclude-tags "pty || serial" --timeout 60s
|
||||
echo "==> flutter test --coverage (serial-tagged; --concurrency=1)"
|
||||
flutter test -r "$REPORTER" --coverage --tags serial --concurrency=1 --timeout 60s
|
||||
flutter test -r "$REPORTER" --coverage --coverage-path "$COV_TMP/serial.info" --tags serial --concurrency=1 --timeout 60s
|
||||
echo "==> merge coverage (parallel + serial passes → coverage/lcov.info)"
|
||||
python3 ci/merge_lcov.py coverage/lcov.parallel.info coverage/lcov.info > coverage/lcov.merged.info
|
||||
mv coverage/lcov.merged.info coverage/lcov.info
|
||||
rm -f coverage/lcov.parallel.info
|
||||
mkdir -p coverage
|
||||
python3 ci/merge_lcov.py "$COV_TMP/parallel.info" "$COV_TMP/serial.info" > "coverage/.lcov.$$.info"
|
||||
mv -f "coverage/.lcov.$$.info" coverage/lcov.info
|
||||
else
|
||||
echo "==> flutter test (dev; parallel pool, excludes pty + serial)"
|
||||
flutter test -r "$REPORTER" --exclude-tags "pty || serial" --concurrency=12 --timeout 60s
|
||||
|
||||
+17
-24
@@ -1,13 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/test_core.sh — run the Flutter-free core Dart tests.
|
||||
#
|
||||
# Covers `test/` at the repo root (IPC, daemon, PTY). Wraps `dart test`
|
||||
# in a hard timeout + process-group kill so a hanging test (typically
|
||||
# one holding a native fd open) can't wedge CI or pre-push.
|
||||
# Covers `test/` at the repo root (IPC, daemon, PTY, git, panes, files,
|
||||
# editor, pql). Each pass runs under `dart test --timeout` so a hanging
|
||||
# test (typically one holding a native fd open) fails fast instead of
|
||||
# wedging CI or the pre-push gate — the same portable mechanism ci/test.sh
|
||||
# uses for the Flutter suite. No external `timeout`/`setsid` wrapper: those
|
||||
# are GNU coreutils and absent on macOS, where their failure silently
|
||||
# skipped the whole suite.
|
||||
#
|
||||
# Rationale: D-030 makes tests client-side only; a hang here is always
|
||||
# local — either a real bug or a bad test. Either way we'd rather fail
|
||||
# loudly at 120s than block a pre-push indefinitely.
|
||||
# loudly at the per-test timeout than block a pre-push indefinitely.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -20,32 +24,21 @@ if ! command -v dart >/dev/null; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
|
||||
# tests are faster still. 120s is generous for CI warmup, tiny for a
|
||||
# hang.
|
||||
TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
||||
# Per-test hard timeout. The PTY tests should finish in <5s; IPC/daemon
|
||||
# tests are faster still. 60s is generous for CI warmup, tiny for a hang.
|
||||
# Matches ci/test.sh's --timeout 60s.
|
||||
TEST_TIMEOUT="${TEST_TIMEOUT:-60s}"
|
||||
|
||||
# failures-only: print failing tests + a final count, not one line per test.
|
||||
# Override with TEST_REPORTER=expanded when debugging. (T-242)
|
||||
REPORTER="${TEST_REPORTER:-failures-only}"
|
||||
|
||||
# Run dart test in its own process group so we can kill descendants on
|
||||
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
||||
# after SIGTERM if the test ignores it.
|
||||
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql"
|
||||
|
||||
# Run a `dart test` pass under the hard timeout + process-group kill.
|
||||
# Run a `dart test` pass under the per-test timeout. set -e propagates a
|
||||
# failing pass (including a --timeout-induced failure) with dart's exit code.
|
||||
run_pass() {
|
||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
||||
setsid --wait dart test -r "$REPORTER" "$@" ; then
|
||||
rc=$?
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
echo "test-core: TIMEOUT — killing descendants" >&2
|
||||
pkill -9 -f "dart test" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
fi
|
||||
dart test -r "$REPORTER" --timeout "$TEST_TIMEOUT" "$@"
|
||||
}
|
||||
|
||||
# Some core tests must not share the parallel pool:
|
||||
@@ -58,10 +51,10 @@ run_pass() {
|
||||
# record_id migration.)
|
||||
# Run both in one --concurrency=1 pass (matching ci/test.sh's serial handling),
|
||||
# then everything else in parallel.
|
||||
echo "test-core: dart test (pty + serial; --concurrency=1) (timeout ${TIMEOUT_SECONDS}s)"
|
||||
echo "test-core: dart test (pty + serial; --concurrency=1) (timeout ${TEST_TIMEOUT})"
|
||||
run_pass --concurrency=1 --tags "pty || serial" $CORE_DIRS
|
||||
|
||||
echo "test-core: dart test (rest; parallel, excludes pty + serial) (timeout ${TIMEOUT_SECONDS}s)"
|
||||
echo "test-core: dart test (rest; parallel, excludes pty + serial) (timeout ${TEST_TIMEOUT})"
|
||||
run_pass --exclude-tags "pty || serial" $CORE_DIRS
|
||||
|
||||
echo "test-core: ok"
|
||||
|
||||
+12
-8
@@ -33,9 +33,12 @@ One OS process. The Flutter app hosts:
|
||||
- every subsystem handler (pane/files/editor/git/pql),
|
||||
- the extension manager and all built-in extensions.
|
||||
|
||||
`tmux` is the only external long-lived process — it owns Claude
|
||||
session persistence so panes survive app restarts (D-41). The app
|
||||
re-attaches via `tmux new-session -A` on boot.
|
||||
The Claude pane is driven over Claude Code's stream-json stdio control
|
||||
protocol — clide spawns the `claude` child directly and renders its
|
||||
event stream natively (D-75/D-77/D-78). Session continuity is
|
||||
`--resume <session-id>` (state lives in Claude's transcript files), not
|
||||
a long-lived wrapper process. `tmux` is no longer in the Claude path; it
|
||||
is retained only by the general-purpose terminal builtin.
|
||||
|
||||
PTYs are spawned natively from Dart. `lib/src/pty/native_pty.dart`
|
||||
calls `posix_openpt()` + `posix_spawn()` via FFI; the child inherits
|
||||
@@ -74,11 +77,12 @@ state-changing command emits one or more events on a long-lived
|
||||
event stream; every UI affordance has a matching CLI verb. See D-6
|
||||
for the subsystem/verb/event contract.
|
||||
|
||||
> **Caveat (2026-05):** the Unix-socket server that exposes the
|
||||
> dispatcher to a thin `clide` C client is currently unimplemented.
|
||||
> Today's working path is in-process direct dispatch. See **T-99**
|
||||
> (IPC server implementation) and **D-68** (dual integration surface
|
||||
> — Bash CLI primary, MCP secondary).
|
||||
The Unix-socket server that exposes the dispatcher to a thin `clide` C
|
||||
client (`native/clide-cli/clide.c`) is implemented in
|
||||
`lib/src/ipc/server.dart`; the socket path, access control, and dispatch
|
||||
model are pinned by D-70/D-71/D-72. In-process direct dispatch remains
|
||||
the path for the Flutter app's own subsystem calls. See **D-68** (dual
|
||||
integration surface — Bash CLI primary, MCP secondary).
|
||||
|
||||
### User-facing — Flutter desktop
|
||||
|
||||
|
||||
@@ -10,13 +10,7 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class ClideTheme {
|
||||
const ClideTheme({
|
||||
required this.name,
|
||||
required this.dark,
|
||||
required this.subtitle,
|
||||
required this.palette,
|
||||
required this.syntax,
|
||||
});
|
||||
const ClideTheme({required this.name, required this.dark, required this.subtitle, required this.palette, required this.syntax});
|
||||
final String name;
|
||||
final bool dark;
|
||||
final String subtitle;
|
||||
|
||||
@@ -79,8 +79,8 @@ The widget is a thin shell:
|
||||
- Calls `bodyBuilder(active)` for the visible content
|
||||
- Routes user gestures to controller methods or callbacks
|
||||
- Emits `onCloseRequested` / `onAddRequested` so the host decides
|
||||
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
|
||||
doesn't just append a UI tab)
|
||||
the actual lifecycle (e.g. the Claude pane spawns a new stream-json
|
||||
session, doesn't just append a UI tab)
|
||||
|
||||
The host owns the controller and the payload type. The widget never
|
||||
touches PTY, IPC, or Claude session naming.
|
||||
@@ -138,7 +138,7 @@ ClaudePane (host)
|
||||
)
|
||||
```
|
||||
|
||||
`ClaudeSessionRef` carries the tmux session name + isPrimary. The
|
||||
`ClaudeSessionRef` carries the stream-json session id + isPrimary. The
|
||||
controller is seeded with `[primary]` on boot; secondaries get
|
||||
appended as the user clicks `+`. Closing a secondary triggers
|
||||
`pane.close` IPC and removes the entry; closing the primary is not
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"name": "Ticket Panel — Type Filters",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 80, "top": 60, "width": 400, "height": 520,
|
||||
"fillColor": "#0d0d11",
|
||||
"strokeColor": "#23232b",
|
||||
"corners": [6, 6, 6, 6]
|
||||
},
|
||||
|
||||
"filter-box": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 76, "width": 368, "height": 30,
|
||||
"fillColor": "#16161c",
|
||||
"strokeColor": "#2c2c36",
|
||||
"corners": [5, 5, 5, 5]
|
||||
},
|
||||
"filter-icon": {
|
||||
"type": "Text",
|
||||
"parent": "filter-box",
|
||||
"left": 108, "top": 84,
|
||||
"text": "⌕",
|
||||
"fontColor": "#5b5b66",
|
||||
"fontSize": 13
|
||||
},
|
||||
"filter-text": {
|
||||
"type": "Text",
|
||||
"parent": "filter-box",
|
||||
"left": 126, "top": 85,
|
||||
"text": "Filter tickets…",
|
||||
"fontColor": "#5b5b66",
|
||||
"fontSize": 12
|
||||
},
|
||||
"refresh-icon": {
|
||||
"type": "Text",
|
||||
"parent": "filter-box",
|
||||
"left": 442, "top": 84,
|
||||
"text": "↻",
|
||||
"fontColor": "#5b5b66",
|
||||
"fontSize": 13
|
||||
},
|
||||
|
||||
"chip-init": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 118, "width": 86, "height": 24,
|
||||
"fillColor": "#1a151f",
|
||||
"strokeColor": "#C792EA",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"chip-init-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "chip-init",
|
||||
"left": 104, "top": 126, "width": 8, "height": 8,
|
||||
"fillColor": "#C792EA",
|
||||
"strokeColor": "#C792EA"
|
||||
},
|
||||
"chip-init-label": {
|
||||
"type": "Text",
|
||||
"parent": "chip-init",
|
||||
"left": 117, "top": 124,
|
||||
"text": "Initiative",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"chip-epic": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 188, "top": 118, "width": 58, "height": 24,
|
||||
"fillColor": "#14171f",
|
||||
"strokeColor": "#78A0F8",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"chip-epic-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "chip-epic",
|
||||
"left": 196, "top": 126, "width": 8, "height": 8,
|
||||
"fillColor": "#78A0F8",
|
||||
"strokeColor": "#78A0F8"
|
||||
},
|
||||
"chip-epic-label": {
|
||||
"type": "Text",
|
||||
"parent": "chip-epic",
|
||||
"left": 209, "top": 124,
|
||||
"text": "Epic",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"chip-story": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 252, "top": 118, "width": 62, "height": 24,
|
||||
"fillColor": "#15191d",
|
||||
"strokeColor": "#7DD3A8",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"chip-story-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "chip-story",
|
||||
"left": 260, "top": 126, "width": 8, "height": 8,
|
||||
"fillColor": "#7DD3A8",
|
||||
"strokeColor": "#7DD3A8"
|
||||
},
|
||||
"chip-story-label": {
|
||||
"type": "Text",
|
||||
"parent": "chip-story",
|
||||
"left": 273, "top": 124,
|
||||
"text": "Story",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"chip-task": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 320, "top": 118, "width": 56, "height": 24,
|
||||
"fillColor": "#17181a",
|
||||
"strokeColor": "#9AA0AA",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"chip-task-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "chip-task",
|
||||
"left": 328, "top": 126, "width": 8, "height": 8,
|
||||
"fillColor": "#9AA0AA",
|
||||
"strokeColor": "#9AA0AA"
|
||||
},
|
||||
"chip-task-label": {
|
||||
"type": "Text",
|
||||
"parent": "chip-task",
|
||||
"left": 341, "top": 124,
|
||||
"text": "Task",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"chip-bug": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 382, "top": 118, "width": 54, "height": 24,
|
||||
"fillColor": "#211519",
|
||||
"strokeColor": "#E87D7D",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"chip-bug-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "chip-bug",
|
||||
"left": 390, "top": 126, "width": 8, "height": 8,
|
||||
"fillColor": "#E87D7D",
|
||||
"strokeColor": "#E87D7D"
|
||||
},
|
||||
"chip-bug-label": {
|
||||
"type": "Text",
|
||||
"parent": "chip-bug",
|
||||
"left": 403, "top": 124,
|
||||
"text": "Bug",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"divider": {
|
||||
"type": "Line",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 158, "width": 368, "height": 1,
|
||||
"strokeColor": "#23232b"
|
||||
},
|
||||
|
||||
"section-header": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 98, "top": 172,
|
||||
"text": "▾ BACKLOG · 61",
|
||||
"fontColor": "#6b6b76",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"card-1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 192, "width": 368, "height": 56,
|
||||
"fillColor": "#0d0d11",
|
||||
"strokeColor": "#23232b",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"card-1-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "card-1",
|
||||
"left": 108, "top": 204, "width": 8, "height": 8,
|
||||
"fillColor": "#C792EA",
|
||||
"strokeColor": "#C792EA"
|
||||
},
|
||||
"card-1-id": {
|
||||
"type": "Text",
|
||||
"parent": "card-1",
|
||||
"left": 122, "top": 200,
|
||||
"text": "T-8",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
"card-1-title": {
|
||||
"type": "Text",
|
||||
"parent": "card-1",
|
||||
"left": 108, "top": 220,
|
||||
"text": "Tier 6 — extension API, settings, theming, builds",
|
||||
"fontColor": "#b8b8c2",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"card-2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 254, "width": 368, "height": 56,
|
||||
"fillColor": "#0d0d11",
|
||||
"strokeColor": "#23232b",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"card-2-dot": {
|
||||
"type": "Ellipse",
|
||||
"parent": "card-2",
|
||||
"left": 108, "top": 266, "width": 8, "height": 8,
|
||||
"fillColor": "#E87D7D",
|
||||
"strokeColor": "#E87D7D"
|
||||
},
|
||||
"card-2-id": {
|
||||
"type": "Text",
|
||||
"parent": "card-2",
|
||||
"left": 122, "top": 262,
|
||||
"text": "T-19",
|
||||
"fontColor": "#e8e8ee",
|
||||
"fontSize": 11
|
||||
},
|
||||
"card-2-title": {
|
||||
"type": "Text",
|
||||
"parent": "card-2",
|
||||
"left": 108, "top": 282,
|
||||
"text": "Filter box loses focus on refresh tick",
|
||||
"fontColor": "#b8b8c2",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"caption": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 96, "top": 540,
|
||||
"text": "All five type filters ON by default — click a chip to toggle, double-click to isolate.",
|
||||
"fontColor": "#56565f",
|
||||
"fontSize": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -8,10 +8,10 @@ on any Linux or macOS dev box without network access or shared state.
|
||||
|
||||
| layer | location | runner | time | when |
|
||||
|---|---|---|---|---|
|
||||
| unit (root) | `test/` | `dart test` | ~5s | `make test` |
|
||||
| unit + widget + golden (app) | `app/test/` | `flutter test` | ~30s | `make test` |
|
||||
| a11y contract | `app/test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
|
||||
| integration (startup gate) | `app/integration_test/` | `flutter test integration_test/` | ~60s | `make test-integration` |
|
||||
| unit (Flutter-free core) | `test/ipc/`, `test/daemon/`, `test/pty/` | `dart test` | ~5s | `make test-core` |
|
||||
| unit + widget + golden | `test/` | `flutter test` | ~30s | `make test` |
|
||||
| a11y contract | `test/a11y/` | `flutter test` | ~5s | `make test-a11y` |
|
||||
| integration (startup gate) | `integration_test/` | `flutter test integration_test/` | ~60s | `make test-integration` |
|
||||
| daemon E2E + web WASM smoke | `test/daemon/` + `tools/ui/tests/` | `dart test` + Playwright | ~60s | `make test-e2e` |
|
||||
| startup bundle smoke | `ci/smoke_bundle.sh` | xvfb-run, 5s timeout | ~30s | `make smoke-bundle` |
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Claude Code needs to *actually use* the app while building features.
|
||||
The pipeline:
|
||||
|
||||
1. **Flutter builds the app to WASM.** `flutter build web --wasm` ships
|
||||
a CanvasKit/Skwasm bundle under `app/build/web/`.
|
||||
a CanvasKit/Skwasm bundle under `build/web/`.
|
||||
2. **A local server serves it.** `tools/ui/serve.sh` starts
|
||||
`http://localhost:4280` in the background with a pidfile.
|
||||
3. **Playwright drives a headless Chromium.** Instead of click-by-pixel
|
||||
|
||||
+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).
|
||||
|
||||
---
|
||||
@@ -33,23 +33,13 @@ void main() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -25,22 +25,13 @@ void main() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -16,22 +16,13 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.theme-picker', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
+12
-1184
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);
|
||||
|
||||
@@ -42,7 +42,8 @@ const List<String> clideAllowedToolsArgs = ['--allowedTools', clideBashAllowRule
|
||||
/// the orient-snapshot (`clide status`) and live pane/editor reflection
|
||||
/// arrive with Epic C (T-218..T-221) and are deliberately left out so the
|
||||
/// note never points the agent at a command that returns nothing yet.
|
||||
String clideContextNote(String workspaceRoot) => 'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
|
||||
String clideContextNote(String workspaceRoot) =>
|
||||
'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
|
||||
'surface as a `clide` command on your PATH; drive it with `clide <subsystem> <verb>`. '
|
||||
'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
|
||||
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), '
|
||||
@@ -61,16 +62,8 @@ String clideContextNote(String workspaceRoot) => 'You are running inside clide,
|
||||
/// * `CLIDE_WORKSPACE` — the workspace root.
|
||||
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
|
||||
/// is not already resolvable), otherwise left untouched.
|
||||
Map<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
}) {
|
||||
final delta = <String, String>{
|
||||
'CLIDE_SOCK': socketPath,
|
||||
'CLIDE_WORKSPACE': workspaceRoot,
|
||||
};
|
||||
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
|
||||
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
|
||||
if (clideCliDir != null && clideCliDir.isNotEmpty) {
|
||||
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
|
||||
}
|
||||
@@ -85,11 +78,7 @@ Map<String, String> agentEnvDelta({
|
||||
///
|
||||
/// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes
|
||||
/// `<dir>/clide`. Both are injected so the resolver is pure and testable.
|
||||
String? resolveClideCliDir({
|
||||
required String? currentPath,
|
||||
required List<String> candidateDirs,
|
||||
required bool Function(String path) isExecutableFile,
|
||||
}) {
|
||||
String? resolveClideCliDir({required String? currentPath, required List<String> candidateDirs, required bool Function(String path) isExecutableFile}) {
|
||||
if (currentPath != null) {
|
||||
for (final dir in currentPath.split(':')) {
|
||||
if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null;
|
||||
@@ -142,21 +131,9 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(
|
||||
currentPath: currentPath,
|
||||
candidateDirs: candidates,
|
||||
isExecutableFile: _isExecutableFile,
|
||||
);
|
||||
final delta = agentEnvDelta(
|
||||
workspaceRoot: workspaceRoot,
|
||||
socketPath: workspaceSocketPath(workspaceRoot),
|
||||
currentPath: currentPath,
|
||||
clideCliDir: cliDir,
|
||||
);
|
||||
return AgentBootstrap(
|
||||
envDelta: {...?base, ...delta},
|
||||
extraArgs: ['--allowedTools', clideBashAllowRule],
|
||||
);
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
}
|
||||
|
||||
bool _isExecutableFile(String path) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/// Detect the file a Bash command follows, for the live-tail sub-card (T-325).
|
||||
///
|
||||
/// Claude Code runs every Bash tool itself and clide only sees the final
|
||||
/// `tool_result` block — we never tap the running command's stdout. So instead
|
||||
/// of mirroring the process, we detect a *file-backed source* the command
|
||||
/// reads/follows and open our own read-only follower on the same file.
|
||||
///
|
||||
/// Deliberately conservative (the ticket's "small, explicit allowlist"): only
|
||||
/// the read/follow verbs below, only a single file argument, and only paths
|
||||
/// that resolve INSIDE the workspace. Anything else — a pipe into `tail`, a
|
||||
/// redirect, two files, a path outside the repo — returns null so the caller
|
||||
/// shows a "nothing to follow" affordance instead of following the wrong thing.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/path_safety.dart';
|
||||
|
||||
/// Verbs whose single file argument clide can independently follow read-only.
|
||||
const Set<String> _followVerbs = {'tail', 'cat', 'less'};
|
||||
|
||||
/// The file [command] reads/follows that clide can mirror read-only, as an
|
||||
/// absolute path inside [workspaceRoot] — or null when there is no single,
|
||||
/// safe, file-backed source. See the library doc for the policy.
|
||||
String? detectBashTailSource(String command, {required Directory workspaceRoot}) {
|
||||
String? found;
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty || !_followVerbs.contains(tokens.first)) continue;
|
||||
final files = _fileArgs(tokens.first, tokens.sublist(1));
|
||||
if (files.length != 1) continue; // 0 → reads stdin (a pipe); >1 → ambiguous
|
||||
|
||||
final String resolved;
|
||||
try {
|
||||
resolved = resolveUnderRoot(workspaceRoot, files.single);
|
||||
} on PathOutsideRoot {
|
||||
continue; // outside the workspace → don't follow (v1 policy)
|
||||
}
|
||||
if (found != null && found != resolved) return null; // two distinct sources
|
||||
found = resolved;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Whether [command] expresses an intent to FOLLOW a file — used to decide
|
||||
/// when to surface the live-tail segment at all, so ordinary commands (`ls`,
|
||||
/// `git status`, a plain `cat`) get no segment, but a `tail …` with no
|
||||
/// followable file still shows the "nothing to follow" note. v1 triggers on
|
||||
/// `tail` or a follow flag (`-f`/`-F`/`--follow`); `cat`/`less` are detectable
|
||||
/// sources but don't trigger the UI on their own (T-325).
|
||||
bool bashHasTailIntent(String command) {
|
||||
for (final segment in _commandSegments(command)) {
|
||||
final tokens = _tokenize(segment);
|
||||
if (tokens.isEmpty) continue;
|
||||
if (tokens.first == 'tail') return true;
|
||||
if (tokens.any((t) => t == '-f' || t == '-F' || t == '--follow')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Split a command line into command/pipeline segments on `|`, `;`, `&`. The
|
||||
/// doubled forms (`&&`, `||`) fall out as empty middles and are dropped.
|
||||
Iterable<String> _commandSegments(String command) => command.split(RegExp(r'[|;&]')).where((s) => s.trim().isNotEmpty);
|
||||
|
||||
/// Positional (non-flag) file arguments for [verb]. Skips flags, consumes the
|
||||
/// value of `tail -n N` / `-c N`, honours `--` (end of options), and stops at a
|
||||
/// redirect (`>` / `<`) — everything after a redirect targets a fd, not the
|
||||
/// command's input.
|
||||
List<String> _fileArgs(String verb, List<String> args) {
|
||||
final files = <String>[];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
final a = args[i];
|
||||
if (a == '--') {
|
||||
files.addAll(args.sublist(i + 1).where((t) => !t.contains('>') && !t.contains('<')));
|
||||
break;
|
||||
}
|
||||
if (a.contains('>') || a.contains('<')) break; // a redirect ends positional args
|
||||
if (a.startsWith('-')) {
|
||||
if (verb == 'tail' && (a == '-n' || a == '-c') && i + 1 < args.length) i++; // -n N / -c N
|
||||
continue;
|
||||
}
|
||||
files.add(a);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// Minimal shell tokeniser: splits on whitespace, honours single/double quotes
|
||||
/// (no escape or expansion handling — enough to recover file arguments).
|
||||
List<String> _tokenize(String s) {
|
||||
final out = <String>[];
|
||||
final buf = StringBuffer();
|
||||
String? quote;
|
||||
var has = false;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
final ch = s[i];
|
||||
if (quote != null) {
|
||||
if (ch == quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
has = true;
|
||||
} else if (ch == '"' || ch == "'") {
|
||||
quote = ch;
|
||||
has = true;
|
||||
} else if (ch == ' ' || ch == '\t') {
|
||||
if (has) {
|
||||
out.add(buf.toString());
|
||||
buf.clear();
|
||||
has = false;
|
||||
}
|
||||
} else {
|
||||
buf.write(ch);
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
if (has) out.add(buf.toString());
|
||||
return out;
|
||||
}
|
||||
@@ -35,20 +35,12 @@ class ClaudeBanner extends StatelessWidget {
|
||||
children: [
|
||||
const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60),
|
||||
const SizedBox(height: 18),
|
||||
ClideText(
|
||||
'Claude',
|
||||
fontSize: clideFontDialogTitle,
|
||||
color: claudeAccent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 16),
|
||||
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
||||
if (statusLine != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
|
||||
const SizedBox(height: 16),
|
||||
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
||||
],
|
||||
|
||||
@@ -352,7 +352,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyHistory(String text) => _applyValue(TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)));
|
||||
void _applyHistory(String text) => _applyValue(
|
||||
TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
),
|
||||
);
|
||||
|
||||
/// Set the controller without it being treated as a user edit (so the
|
||||
/// preview doesn't overwrite the persisted draft or exit navigation).
|
||||
@@ -368,10 +373,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
final tokens = _attachments.map((a) => a.pathToken);
|
||||
if (text.trim().isEmpty && _attachments.isEmpty) return;
|
||||
// Typed text first, then the attachment @path references.
|
||||
final message = [
|
||||
if (text.trim().isNotEmpty) text,
|
||||
...tokens,
|
||||
].join(' ');
|
||||
final message = [if (text.trim().isNotEmpty) text, ...tokens].join(' ');
|
||||
widget.onSubmit(message);
|
||||
_controller.clear();
|
||||
setState(() => _attachments.clear());
|
||||
@@ -454,11 +456,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
if (_attachments.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final a in _attachments) _chip(theme, a)],
|
||||
),
|
||||
child: Wrap(spacing: 6, runSpacing: 6, children: [for (final a in _attachments) _chip(theme, a)]),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -489,13 +487,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!hasText)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
|
||||
),
|
||||
if (!hasText) Positioned(left: 0, top: 0, right: 0, child: ClideText(widget.hint, muted: true, fontSize: clideFontBody)),
|
||||
EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
@@ -516,10 +508,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: PermissionModeControl(
|
||||
mode: widget.permissionMode!,
|
||||
onSelect: widget.onSetPermissionMode!,
|
||||
),
|
||||
child: PermissionModeControl(mode: widget.permissionMode!, onSelect: widget.onSetPermissionMode!),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -548,12 +537,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
_chipLeading(theme, a),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
a.fileName,
|
||||
fontSize: clideFontSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: ClideText(a.fileName, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Semantics(
|
||||
|
||||
@@ -94,13 +94,7 @@ class ClaudePermissions {
|
||||
/// + plugin + MCP), the skill names, the default model and permission mode.
|
||||
@immutable
|
||||
class ClaudeProbe {
|
||||
const ClaudeProbe({
|
||||
required this.version,
|
||||
required this.slashCommands,
|
||||
required this.skills,
|
||||
this.model,
|
||||
this.permissionMode,
|
||||
});
|
||||
const ClaudeProbe({required this.version, required this.slashCommands, required this.skills, this.model, this.permissionMode});
|
||||
|
||||
final String version;
|
||||
final List<String> slashCommands;
|
||||
@@ -259,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();
|
||||
}
|
||||
@@ -302,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();
|
||||
}
|
||||
@@ -400,10 +401,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
/// Global first so that local entries, added later, win on collisions.
|
||||
List<(ConfigScope, Directory)> _scopeDirs() {
|
||||
final pd = _projectDir;
|
||||
return [
|
||||
(ConfigScope.global, _globalDir),
|
||||
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
|
||||
];
|
||||
return [(ConfigScope.global, _globalDir), if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude'))];
|
||||
}
|
||||
|
||||
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
|
||||
@@ -415,12 +413,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
final manifest = File('${entry.path}/SKILL.md');
|
||||
if (!await manifest.exists()) continue;
|
||||
final fm = _parseFrontmatter(await manifest.readAsString());
|
||||
out.add(ClaudeSkill(
|
||||
name: fm.name ?? _basename(entry.path),
|
||||
description: fm.description,
|
||||
scope: scope,
|
||||
path: manifest.path,
|
||||
));
|
||||
out.add(ClaudeSkill(name: fm.name ?? _basename(entry.path), description: fm.description, scope: scope, path: manifest.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -432,11 +425,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! File || !entry.path.endsWith('.md')) continue;
|
||||
final base = _basename(entry.path);
|
||||
out.add(ClaudeCommand(
|
||||
name: base.substring(0, base.length - 3),
|
||||
scope: scope,
|
||||
path: entry.path,
|
||||
));
|
||||
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -449,11 +438,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! File || !entry.path.endsWith('.md')) continue;
|
||||
final base = _basename(entry.path);
|
||||
out.add(ClaudeAgent(
|
||||
name: base.substring(0, base.length - 3),
|
||||
scope: scope,
|
||||
path: entry.path,
|
||||
));
|
||||
out.add(ClaudeAgent(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -472,11 +457,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
|
||||
final p = settings['permissions'];
|
||||
if (p is! Map) return const ClaudePermissions();
|
||||
return ClaudePermissions(
|
||||
allow: _stringList(p['allow']),
|
||||
deny: _stringList(p['deny']),
|
||||
ask: _stringList(p['ask']),
|
||||
);
|
||||
return ClaudePermissions(allow: _stringList(p['allow']), deny: _stringList(p['deny']), ask: _stringList(p['ask']));
|
||||
}
|
||||
|
||||
// ---- Watching -----------------------------------------------------------
|
||||
@@ -573,9 +554,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
|
||||
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
|
||||
if (raw is! Map) return const [];
|
||||
return [
|
||||
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
|
||||
];
|
||||
return [for (final key in raw.keys) ClaudeMcpServer(name: '$key')];
|
||||
}
|
||||
|
||||
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
|
||||
@@ -608,14 +587,7 @@ Future<String?> _defaultVersionRunner() async {
|
||||
|
||||
Future<String?> _defaultInitProbe() async {
|
||||
try {
|
||||
final r = await Process.run('claude', [
|
||||
'-p',
|
||||
'.',
|
||||
'--no-session-persistence',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
]);
|
||||
final r = await Process.run('claude', ['-p', '.', '--no-session-persistence', '--output-format', 'stream-json', '--verbose']);
|
||||
return r.stdout as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import 'claude_banner.dart';
|
||||
import 'claude_composer.dart';
|
||||
import 'claude_config.dart';
|
||||
import 'claude_status.dart';
|
||||
import 'claude_task_dock.dart';
|
||||
import 'clipboard_paste.dart';
|
||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||
import 'conversation_controller.dart';
|
||||
@@ -21,6 +22,7 @@ import 'session_orchestrator.dart';
|
||||
import 'session_picker.dart';
|
||||
import 'slash_commands.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'task_list.dart';
|
||||
import 'transcript_reader.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
@@ -69,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;
|
||||
@@ -78,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
|
||||
@@ -139,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;
|
||||
@@ -166,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;
|
||||
@@ -195,9 +208,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
sub.cancel();
|
||||
if (!c.isCompleted) c.complete();
|
||||
});
|
||||
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
await c.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
sub.cancel();
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
}
|
||||
return _spawn();
|
||||
@@ -219,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;
|
||||
@@ -258,24 +276,24 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
final ManagedSession managed;
|
||||
final forkSource = widget.forkSourceId;
|
||||
final forkSource = _forkSource;
|
||||
if (forkSource != null) {
|
||||
// Fork pane: branch source session into a new clide-managed session.
|
||||
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||
// assigned by `--fork-session` and arrives in the init event (T-172).
|
||||
_sessionId ??= freshSessionId();
|
||||
try {
|
||||
managed = await orch.spawn(SpawnSpec(
|
||||
id: _orchId,
|
||||
role: 'fork ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
forkSourceSessionId: forkSource,
|
||||
));
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
return;
|
||||
}
|
||||
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
|
||||
// made /clear re-fork the original conversation instead of clearing —
|
||||
// every later respawn must operate on this pane's own session (T-375).
|
||||
_forkSource = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _statusLine = 'fork of $forkSource');
|
||||
} else {
|
||||
@@ -289,14 +307,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final resume = await File(transcriptFile).exists();
|
||||
|
||||
try {
|
||||
managed = await orch.spawn(SpawnSpec(
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
));
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start claude: $e');
|
||||
return;
|
||||
@@ -311,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)'}',
|
||||
@@ -320,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
|
||||
@@ -419,18 +457,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// re-bind the pane to it.
|
||||
Future<void> _resumeFlow() async {
|
||||
final root = _repoRoot;
|
||||
final dialog = _kernel()?.dialog;
|
||||
final dialog = _kernel?.dialog;
|
||||
if (root == null || dialog == null) return;
|
||||
final dir = Directory(claudeProjectDir(root));
|
||||
final sessions = await listSessions(dir);
|
||||
if (!mounted) return;
|
||||
final picked = await dialog.show<String>(
|
||||
(ctx, dismiss) => SessionPickerDialog(
|
||||
sessions: sessions,
|
||||
onPick: (id) => dismiss(id),
|
||||
onCancel: dismiss,
|
||||
),
|
||||
);
|
||||
final picked = await dialog.show<String>((ctx, dismiss) => SessionPickerDialog(sessions: sessions, onPick: (id) => dismiss(id), onCancel: dismiss));
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _statusLine = 'resuming…');
|
||||
await _respawnWithSession(picked);
|
||||
@@ -443,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;
|
||||
@@ -458,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 ----------------------------------------------------------------
|
||||
|
||||
@@ -477,10 +506,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
body = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
body = Padding(padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true));
|
||||
} else if (_conversation != null) {
|
||||
// Rebuild conversation + composer zone together on each prompt change so
|
||||
// the view hides a prompted tool-use card the moment its prompt appears
|
||||
@@ -502,9 +528,10 @@ 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>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
workspace: _repoRoot,
|
||||
@@ -513,6 +540,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Claude's task list, docked above the composer (T-308). Rebuilds
|
||||
// with the conversation; renders nothing when there are no tasks.
|
||||
ListenableBuilder(
|
||||
listenable: _conversation!,
|
||||
builder: (_, _) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
// conversation stream (D-78).
|
||||
@@ -548,22 +581,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
body = const Center(child: ClideText('starting…', muted: true));
|
||||
}
|
||||
|
||||
final content = widget.showChrome
|
||||
? ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
|
||||
|
||||
// Surface this pane's status to the bottom status-bar slot while it's
|
||||
// the focused pane (T-150).
|
||||
return ClidePane(
|
||||
contributionId: widget.contributionId,
|
||||
active: widget.active,
|
||||
statusWidget: _statusWidget(tokens),
|
||||
child: content,
|
||||
);
|
||||
return ClidePane(contributionId: widget.contributionId, active: widget.active, statusWidget: _statusWidget(tokens), child: content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,13 +603,7 @@ class _ModeBadge extends StatelessWidget {
|
||||
return Semantics(
|
||||
label: 'permission mode: ${permissionModeLabel(mode)}',
|
||||
excludeSemantics: true,
|
||||
child: ClideText(
|
||||
permissionModeLabel(mode),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: permissionModeColor(mode, tokens),
|
||||
maxLines: 1,
|
||||
),
|
||||
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
/// Public entry point used by the `claude.new-secondary` command.
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'session $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
|
||||
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
/// without touching the original.
|
||||
void addFork(String sourceClaudeSessionId) {
|
||||
final index = _nextSecondary++;
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'fork $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,12 +8,7 @@ library;
|
||||
import 'dart:convert';
|
||||
|
||||
class DailyActivity {
|
||||
const DailyActivity({
|
||||
required this.date,
|
||||
required this.messageCount,
|
||||
required this.sessionCount,
|
||||
required this.toolCallCount,
|
||||
});
|
||||
const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
|
||||
|
||||
final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
|
||||
final int messageCount;
|
||||
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
|
||||
if (da is List) {
|
||||
for (final e in da) {
|
||||
if (e is! Map) continue;
|
||||
daily.add(DailyActivity(
|
||||
daily.add(
|
||||
DailyActivity(
|
||||
date: '${e['date']}',
|
||||
messageCount: _int(e['messageCount']),
|
||||
sessionCount: _int(e['sessionCount']),
|
||||
toolCallCount: _int(e['toolCallCount']),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily);
|
||||
|
||||
@@ -62,10 +62,7 @@ String nextSafePermissionMode(String current) {
|
||||
if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
|
||||
if (s.rateLimitInfo != null) s.rateLimitInfo!,
|
||||
].join(' · ');
|
||||
return (
|
||||
leading: s.model != null ? shortModelLabel(s.model!) : null,
|
||||
trailing: trailing.isEmpty ? null : trailing,
|
||||
);
|
||||
return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
|
||||
}
|
||||
|
||||
/// Friendly label for Claude's permission modes.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/// Claude's task list, docked above the composer (T-308).
|
||||
///
|
||||
/// A compact, display-only surface (D-78 — not an interactive control) pinned
|
||||
/// between the conversation and the composer so the user can always see what
|
||||
/// Claude is tracking and how far along it is. Collapsed by default to a
|
||||
/// one-line summary (`N tasks · M done` + the current in-progress item);
|
||||
/// tapping expands the full checklist. Renders nothing when there are no tasks.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeTaskDock extends StatefulWidget {
|
||||
const ClaudeTaskDock({super.key, required this.tasks});
|
||||
|
||||
final List<TaskItem> tasks;
|
||||
|
||||
@override
|
||||
State<ClaudeTaskDock> createState() => _ClaudeTaskDockState();
|
||||
}
|
||||
|
||||
class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasks = widget.tasks;
|
||||
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
||||
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
||||
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
||||
final summary = '${tasks.length} task${tasks.length == 1 ? '' : 's'} · $done done';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
|
||||
child: ClideTappable(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
tooltip: _expanded ? 'Collapse tasks' : 'Expand tasks',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The summary row IS the toggle — a single labelled button node
|
||||
// (its inner text is announced via the label, so exclude it).
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Claude task list, $summary, ${_expanded ? 'expanded' : 'collapsed'}',
|
||||
excludeSemantics: true,
|
||||
child: _summaryRow(tokens, summary, current),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
ClideDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [for (final t in tasks) _taskRow(tokens, t)]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
if (!_expanded && current != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
|
||||
final (String glyph, Color color, String word) = switch (t.status) {
|
||||
TaskStatus.completed => ('check-circle', tokens.statusSuccess, 'done'),
|
||||
TaskStatus.inProgress => ('circle-half', tokens.globalFocus, 'in progress'),
|
||||
TaskStatus.pending => ('circle', tokens.globalTextMuted, 'pending'),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Semantics(
|
||||
label: '${t.text}, $word',
|
||||
container: true,
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(t.text, fontSize: clideFontCaption, color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,16 +99,10 @@ String pasteCacheDir() {
|
||||
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
|
||||
/// path. Returns an empty list when the clipboard holds neither, so the
|
||||
/// composer pastes text instead.
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(
|
||||
ClipboardSource source, {
|
||||
Directory? tempDir,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) async {
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(ClipboardSource source, {Directory? tempDir, DateTime Function() now = DateTime.now}) async {
|
||||
final files = await source.readFiles();
|
||||
if (files.isNotEmpty) {
|
||||
return [
|
||||
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
|
||||
];
|
||||
return [for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p))];
|
||||
}
|
||||
|
||||
final image = await source.readImage();
|
||||
|
||||
@@ -183,20 +183,13 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
if (!_collapsed) ...[
|
||||
const SizedBox(height: 4),
|
||||
widget.body,
|
||||
for (final seg in widget.extraSegments) ...[
|
||||
_segmentLabel(tokens, seg.label),
|
||||
seg.child,
|
||||
],
|
||||
for (final seg in widget.extraSegments) ...[_segmentLabel(tokens, seg.label), seg.child],
|
||||
],
|
||||
],
|
||||
);
|
||||
return Padding(
|
||||
padding: widget.margin,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: _frame(tokens, content),
|
||||
),
|
||||
child: MouseRegion(onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: _frame(tokens, content)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,7 +239,9 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.collapsible) _caret(tokens),
|
||||
ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily),
|
||||
// Header label size matches ClideCollapserCard (clideFontCaption) so
|
||||
// neighbouring cards in the conversation stream align (T-344).
|
||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: clideMonoFamily),
|
||||
// While collapsed, show a one-line gist next to the label so the card
|
||||
// still says what it holds.
|
||||
if (_collapsed && summary != null) ...[
|
||||
@@ -287,11 +282,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: () => setState(() => _collapsed = !_collapsed),
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ClideIcon(
|
||||
_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'),
|
||||
size: 12,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideIcon(_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'), size: 12, color: tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -358,12 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: items[i].onTap,
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: ClideText(
|
||||
items[i].label,
|
||||
fontSize: clideFontMeta,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,11 +21,8 @@ class ConversationController extends ChangeNotifier {
|
||||
/// over stream-json so the pane would otherwise start empty. [onDispose]
|
||||
/// is invoked from [dispose] — wire it to the reader's `dispose` so
|
||||
/// cancelling the view tears down the underlying tail.
|
||||
ConversationController({
|
||||
required Stream<ConversationItem> stream,
|
||||
Iterable<ConversationItem>? seed,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose {
|
||||
ConversationController({required Stream<ConversationItem> stream, Iterable<ConversationItem>? seed, Future<void> Function()? onDispose})
|
||||
: _onDispose = onDispose {
|
||||
if (seed != null) _items.addAll(seed);
|
||||
_sub = stream.listen(_onItem);
|
||||
}
|
||||
@@ -34,13 +31,10 @@ class ConversationController extends ChangeNotifier {
|
||||
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
|
||||
/// [publisher]/[channel]. Decouples the view from the reader so several
|
||||
/// panels can render the same conversation (team work, T-139/T-140).
|
||||
factory ConversationController.fromBus({
|
||||
required MessageBus messages,
|
||||
String channel = ClaudeConversation.leadChannel,
|
||||
Future<void> Function()? onDispose,
|
||||
}) {
|
||||
final stream =
|
||||
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
|
||||
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
|
||||
final stream = messages
|
||||
.subscribe(publisher: ClaudeConversation.publisher, channel: channel)
|
||||
.map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
|
||||
return ConversationController(stream: stream, onDispose: onDispose);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
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';
|
||||
@@ -22,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';
|
||||
|
||||
@@ -33,6 +37,7 @@ class ConversationView extends StatefulWidget {
|
||||
this.emptyState,
|
||||
this.hiddenToolUseIds = const <String>{},
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.foldLevel = FoldLevel.tools,
|
||||
});
|
||||
|
||||
@@ -52,6 +57,13 @@ class ConversationView extends StatefulWidget {
|
||||
/// border instead of being hidden (D-78).
|
||||
final Map<String, bool> toolUseOutcomes;
|
||||
|
||||
/// tool_use_ids whose error result should render folded + muted instead of as
|
||||
/// a loud red failure (T-340) — expected, user-initiated denials the user
|
||||
/// already understands (Deny & simplify). Genuine tool errors (ids not in
|
||||
/// here) keep the prominent expanded-red treatment (T-168). A reusable filter:
|
||||
/// add ids to quiet more error kinds without string-matching their text.
|
||||
final Set<String> quietErrorToolUseIds;
|
||||
|
||||
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
|
||||
/// grid sets this false and wraps all tiles in one shared area so
|
||||
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
||||
@@ -172,15 +184,20 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
/// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
|
||||
/// nested in a holder UNDER the Agent card (T-264), with a successful
|
||||
/// sidechain tool result left out (it folds into its own tool card).
|
||||
({
|
||||
Set<String> ownedSidechainUuids,
|
||||
Map<String, List<UserMessage>> promptsByToolUseId,
|
||||
Map<String, List<ConversationItem>> runByToolUseId,
|
||||
}) _sidechainFold(List<ConversationItem> items) {
|
||||
({Set<String> ownedSidechainUuids, Map<String, List<UserMessage>> promptsByToolUseId, Map<String, List<ConversationItem>> runByToolUseId}) _sidechainFold(
|
||||
List<ConversationItem> items,
|
||||
) {
|
||||
final agentByMsgUuid = <String, AssistantToolUse>{
|
||||
for (final it in items)
|
||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
||||
};
|
||||
// Stream-json tags sidechain items with the spawning Agent's tool-use id
|
||||
// directly (T-338), so map Agent cards by tool-use id for a direct lookup
|
||||
// that doesn't depend on the (transcript-only) parentUuid chain.
|
||||
final agentByToolUseId = <String, AssistantToolUse>{
|
||||
for (final it in items)
|
||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.toolUseId: it,
|
||||
};
|
||||
// Envelope-level chain info (consistent across items sharing a uuid).
|
||||
final parentByUuid = <String, String?>{};
|
||||
final sidechainByUuid = <String, bool>{};
|
||||
@@ -191,7 +208,23 @@ 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.
|
||||
final byTool = item.parentToolUseId;
|
||||
if (byTool != null) {
|
||||
final agent = agentByToolUseId[byTool];
|
||||
if (agent != null) return agent;
|
||||
}
|
||||
var cur = item.uuid;
|
||||
final seen = <String>{};
|
||||
while (seen.add(cur)) {
|
||||
@@ -202,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>{};
|
||||
@@ -235,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);
|
||||
}
|
||||
});
|
||||
@@ -290,6 +327,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
tokens: tokens,
|
||||
collapseTools: true,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
@@ -300,6 +338,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
items: items,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
@@ -310,6 +349,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
edits: edits,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
@@ -347,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
|
||||
@@ -365,6 +405,91 @@ void _openUrl(BuildContext context, String url) {
|
||||
unawaited(ClideKernel.of(context).os.openURL(url));
|
||||
}
|
||||
|
||||
/// Resolve a path-like token from the conversation to an absolute workspace
|
||||
/// file, or null if it doesn't name a real repo file (T-300). Delegates the
|
||||
/// (pure, testable) path logic to [resolveWorkspaceFilePath] with the open
|
||||
/// project root.
|
||||
String? _resolveRepoFile(BuildContext context, String raw) => resolveWorkspaceFilePath(ClideKernel.of(context).project.current?.path, raw);
|
||||
|
||||
/// Resolve [raw] (a path-like token) against the workspace [root] to an absolute
|
||||
/// path, or null if it doesn't name a real file under the repo (T-300). The
|
||||
/// existence check is what keeps prose ("e.g.", "2.2.0") from linkifying.
|
||||
/// Relative tokens resolve against [root]; absolute tokens must already live
|
||||
/// inside it. `..` segments are rejected so a ref can't escape the repo.
|
||||
@visibleForTesting
|
||||
String? resolveWorkspaceFilePath(String? root, String raw) {
|
||||
if (root == null || raw.isEmpty || raw.contains('..')) return null;
|
||||
final abs = raw.startsWith('/') ? raw : '$root/$raw';
|
||||
if (!abs.startsWith('$root/')) return null;
|
||||
return File(abs).existsSync() ? abs : null;
|
||||
}
|
||||
|
||||
/// Open a clicked workspace file reference in the editor, jumping to [line]
|
||||
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
|
||||
void _openFile(BuildContext context, String path, int? line) {
|
||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, '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({
|
||||
@@ -373,6 +498,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
required this.tokens,
|
||||
this.collapseTools = false,
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.toolUseById = const <String, AssistantToolUse>{},
|
||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
@@ -393,6 +519,10 @@ class _ConversationTurn extends StatelessWidget {
|
||||
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
|
||||
final Map<String, bool> toolUseOutcomes;
|
||||
|
||||
/// tool_use_ids whose error result folds quietly instead of expanded-red
|
||||
/// (T-340) — see [ConversationView.quietErrorToolUseIds].
|
||||
final Set<String> quietErrorToolUseIds;
|
||||
|
||||
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
|
||||
final Map<String, AssistantToolUse> toolUseById;
|
||||
|
||||
@@ -443,6 +573,8 @@ class _ConversationTurn extends StatelessWidget {
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
@@ -453,7 +585,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
label: i.isSidechain ? 'agent' : 'claude',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
|
||||
body: ClideMarkdown(
|
||||
i.text,
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
AssistantThinkingMessage() => ConversationCard(
|
||||
// Framed + muted like the context card (T-306).
|
||||
@@ -502,16 +640,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
image: ClideFileImage(m.path),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
|
||||
errorBuilder: (_, _, _) => _imagePlaceholder(m.path),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (caption != null && caption.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
],
|
||||
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -521,11 +656,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -585,6 +716,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
item: r,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
@@ -620,8 +752,23 @@ class _ConversationTurn extends StatelessWidget {
|
||||
// (note E): call input (body) → prompt → returned result.
|
||||
final segments = <CardSegment>[
|
||||
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
|
||||
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
|
||||
if (succeeded && !(isAgent && hasRun)) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
|
||||
CardSegment(
|
||||
label: 'prompt',
|
||||
child: ClideText(p.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
if (succeeded && !(isAgent && hasRun))
|
||||
CardSegment(
|
||||
label: 'result',
|
||||
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
|
||||
),
|
||||
// T-325: a Bash card that follows a file (`tail -f …`) gets a live,
|
||||
// scrolling tail of that file below the result — connected lazily, only
|
||||
// while the card is expanded (the collapser builds segments on expand).
|
||||
if (t.name == 'Bash' && t.input['command'] is String && bashHasTailIntent(t.input['command'] as String))
|
||||
CardSegment(
|
||||
label: 'live tail',
|
||||
child: _BashLiveTail(command: t.input['command'] as String),
|
||||
),
|
||||
];
|
||||
|
||||
// A resolved permission-prompted call is tinted green if approved / red if
|
||||
@@ -675,23 +822,24 @@ class _ConversationTurn extends StatelessWidget {
|
||||
// Error result: render the error message prominently (T-168). If we have
|
||||
// the paired tool_use, show the tool name as a sub-label so the user can
|
||||
// see what failed without expanding.
|
||||
//
|
||||
// Exception (T-340): an expected, user-initiated denial (Deny & simplify)
|
||||
// is noise as a loud red error — the user already knows what they did. Fold
|
||||
// it to a muted, collapsed card. Genuine failures keep the expanded-red look.
|
||||
if (t.isError) {
|
||||
final quiet = quietErrorToolUseIds.contains(t.toolUseId);
|
||||
final multiline = t.content.contains('\n');
|
||||
final errLabel = quiet ? 'denied' : label;
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: accent,
|
||||
borderColor: tokens.statusError,
|
||||
label: paired != null ? '${paired.name} · $label' : label,
|
||||
accent: quiet ? tokens.globalTextMuted : accent,
|
||||
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
|
||||
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
|
||||
copyText: t.content,
|
||||
collapsible: multiline,
|
||||
collapsedByDefault: false, // errors default expanded so they're visible
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
collapsible: quiet || multiline,
|
||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -712,12 +860,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: isOutputTool
|
||||
? ClideCodeBlock(source: t.content, language: 'text')
|
||||
: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -748,6 +891,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
required this.items,
|
||||
required this.tokens,
|
||||
required this.toolUseOutcomes,
|
||||
required this.quietErrorToolUseIds,
|
||||
required this.toolUseById,
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
@@ -757,6 +901,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
final List<ConversationItem> items;
|
||||
final SurfaceTokens tokens;
|
||||
final Map<String, bool> toolUseOutcomes;
|
||||
final Set<String> quietErrorToolUseIds;
|
||||
final Map<String, AssistantToolUse> toolUseById;
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
@@ -777,6 +922,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
@@ -809,6 +955,7 @@ class _EditRunCard extends StatelessWidget {
|
||||
required this.edits,
|
||||
required this.tokens,
|
||||
required this.toolUseOutcomes,
|
||||
required this.quietErrorToolUseIds,
|
||||
required this.toolUseById,
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
@@ -818,6 +965,7 @@ class _EditRunCard extends StatelessWidget {
|
||||
final List<ConversationItem> edits;
|
||||
final SurfaceTokens tokens;
|
||||
final Map<String, bool> toolUseOutcomes;
|
||||
final Set<String> quietErrorToolUseIds;
|
||||
final Map<String, AssistantToolUse> toolUseById;
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
@@ -838,6 +986,7 @@ class _EditRunCard extends StatelessWidget {
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/session_storage.dart';
|
||||
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
|
||||
@@ -20,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';
|
||||
@@ -94,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'});
|
||||
},
|
||||
@@ -105,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'});
|
||||
},
|
||||
@@ -116,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'});
|
||||
},
|
||||
@@ -127,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'});
|
||||
},
|
||||
@@ -138,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'});
|
||||
},
|
||||
@@ -150,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'});
|
||||
},
|
||||
@@ -168,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'});
|
||||
@@ -187,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'});
|
||||
@@ -199,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.
|
||||
@@ -240,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;
|
||||
@@ -253,7 +267,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
}
|
||||
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
|
||||
},
|
||||
),
|
||||
// claude.agent.fork: branch a managed session into a new fork session
|
||||
@@ -268,25 +282,22 @@ 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}';
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $sourceId',
|
||||
sessionId: forkId,
|
||||
cwd: cwd,
|
||||
forkSourceSessionId: source.sessionId,
|
||||
));
|
||||
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
|
||||
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
@@ -304,12 +315,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
// its model · permission-mode · context line here.
|
||||
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
|
||||
// yields width under pressure and ClideMarquee scrolls (T-160).
|
||||
StatusItemContribution(
|
||||
id: 'claude.status-context',
|
||||
priority: 50,
|
||||
flex: 1,
|
||||
build: (_) => const PaneContextStatusItem(),
|
||||
),
|
||||
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -348,6 +354,19 @@ class ClaudeExtension extends ClideExtension {
|
||||
// 'image' message; we inject the matching card into the conversation the
|
||||
// user is looking at (the primary lead, else the first visible session).
|
||||
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
|
||||
|
||||
// A sidebar "pick up" click (T-327) publishes the full ticket; inject it
|
||||
// into the active conversation as a user turn so Claude starts working it.
|
||||
_subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp));
|
||||
}
|
||||
|
||||
/// Hand a picked-up ticket to the active Claude session (T-327/T-339). The
|
||||
/// decision + transition live in [applyTicketPickUp] so they're testable
|
||||
/// without the activation machinery.
|
||||
void _onTicketPickUp(Message m) {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return;
|
||||
unawaited(applyTicketPickUp(m.data, orchestrator: _orchestrator, ipc: ctx.ipc, messages: ctx.messages));
|
||||
}
|
||||
|
||||
/// Close every session that doesn't belong to the newly-active workspace
|
||||
@@ -385,13 +404,15 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(ImageMessage(
|
||||
target.conversation.inject(
|
||||
ImageMessage(
|
||||
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -452,9 +473,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
|
||||
final sessions = await listSessions(dir);
|
||||
await ctx.dialog.show<Object>(
|
||||
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
|
||||
);
|
||||
await ctx.dialog.show<Object>((c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss));
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/// Read-only file follower for the Bash live-tail sub-card (T-325).
|
||||
///
|
||||
/// clide can't see a running Bash command's stdout (Claude Code owns the
|
||||
/// process), so to "watch the same output" we open our OWN read-only follower
|
||||
/// on the file the command tails. This never spawns a process and never
|
||||
/// touches Claude's command — it just reads the file as it grows, like
|
||||
/// `tail -f`, and hands new bytes to [onData].
|
||||
///
|
||||
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
|
||||
/// than using a watcher so it works uniformly across platforms and survives
|
||||
/// truncation/rotation (size shrinking → re-read from the top).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class FileTailFollower {
|
||||
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
|
||||
|
||||
/// Absolute path of the file to follow.
|
||||
final String path;
|
||||
|
||||
/// New bytes appended since the last read (or the initial tail window).
|
||||
final void Function(Uint8List bytes) onData;
|
||||
|
||||
/// On first read, start this many bytes from the end (a `tail -c` window)
|
||||
/// rather than dumping the whole file.
|
||||
final int tailBytes;
|
||||
|
||||
final Duration interval;
|
||||
|
||||
int _pos = 0;
|
||||
bool _primed = false;
|
||||
bool _stopped = false;
|
||||
Timer? _timer;
|
||||
|
||||
/// Begin following: emit the initial tail window, then poll for growth.
|
||||
Future<void> start() async {
|
||||
await pollOnce();
|
||||
if (_stopped) return;
|
||||
_timer = Timer.periodic(interval, (_) => pollOnce());
|
||||
}
|
||||
|
||||
/// One read cycle. Public so tests can drive it deterministically without
|
||||
/// waiting on the timer. Reads any bytes appended since the last position
|
||||
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
|
||||
/// the file shrank (truncated/rotated).
|
||||
Future<void> pollOnce() async {
|
||||
if (_stopped) return;
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return; // not created yet — keep waiting
|
||||
final length = await file.length();
|
||||
|
||||
if (!_primed) {
|
||||
_pos = length > tailBytes ? length - tailBytes : 0;
|
||||
_primed = true;
|
||||
} else if (length < _pos) {
|
||||
_pos = 0; // truncated / rotated → re-read from the top
|
||||
}
|
||||
if (length <= _pos) return;
|
||||
|
||||
final raf = await file.open();
|
||||
try {
|
||||
await raf.setPosition(_pos);
|
||||
final bytes = await raf.read(length - _pos);
|
||||
_pos = length;
|
||||
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following and release the timer. Idempotent.
|
||||
void stop() {
|
||||
_stopped = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,7 @@ void openImageLightbox(BuildContext context, String path) {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(
|
||||
onClose: ctrl.close,
|
||||
minWidth: 180,
|
||||
entries: _entries(ClideTheme.of(ctx).surface),
|
||||
),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
border: Border.all(
|
||||
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
|
||||
),
|
||||
border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// The interactive prompt surface for the stream-json control channel
|
||||
/// (T-166, T-175, T-176, D-78): a permission Allow / Allow-and-remember / Deny,
|
||||
/// (T-166, T-175, T-176, D-78): a permission Allow / Allow-and-remember / Deny /
|
||||
/// Deny-and-simplify (T-311),
|
||||
/// or an `AskUserQuestion` picker (single = bare; multi = stepper + review).
|
||||
/// Rendered in the composer zone (not inline in the conversation) so
|
||||
/// interaction and conversation widgets don't mix — the pane swaps it in for
|
||||
@@ -23,6 +24,16 @@ import 'package:flutter/widgets.dart';
|
||||
/// Sentinel option key for the always-present free-text "Other…" choice.
|
||||
const _kOther = '\u0000other';
|
||||
|
||||
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny
|
||||
/// THIS action and ask Claude to reformulate it more simply, explicitly without
|
||||
/// touching the permission surface (memories / settings).
|
||||
const _kDenySimplifyNote =
|
||||
'Denied — this action is too complex for the permission system to approve cleanly. '
|
||||
'Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) '
|
||||
'to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not '
|
||||
'change permission settings — just reformulate and try again. Do not narrate or explain the change; '
|
||||
'proceed silently with the simpler version.';
|
||||
|
||||
class ToolPromptCard extends StatefulWidget {
|
||||
const ToolPromptCard({super.key, required this.prompt, required this.onResolve});
|
||||
|
||||
@@ -177,6 +188,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
if (n == 1) return _then(_permAllow);
|
||||
if (canRemember && n == 2) return _then(() => _permAllow(remember: true));
|
||||
if (n == (canRemember ? 3 : 2)) return _then(_permDeny);
|
||||
if (n == (canRemember ? 4 : 3)) return _then(_permDenySimplify);
|
||||
return false;
|
||||
}
|
||||
final qi = _currentQuestion();
|
||||
@@ -216,6 +228,18 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
|
||||
void _permDeny() => widget.onResolve(widget.prompt.promptId, DenyTool(_permNote() ?? 'Denied by the user.'));
|
||||
|
||||
/// Deny carrying a preformatted "this was too complex — retry simpler" note
|
||||
/// (T-311). The "do not add a memory / change settings" clause keeps Claude
|
||||
/// from trying to "fix" the permission surface instead of reformulating; the
|
||||
/// user's own typed note, if any, is appended rather than discarded.
|
||||
void _permDenySimplify() {
|
||||
final user = _permNote();
|
||||
final note = user == null ? _kDenySimplifyNote : '$_kDenySimplifyNote\n\nUser note: $user';
|
||||
// Quiet: the user deliberately chose this, so its denial folds rather than
|
||||
// shouting as a red error (T-340).
|
||||
widget.onResolve(widget.prompt.promptId, DenyTool(note, quiet: true));
|
||||
}
|
||||
|
||||
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
|
||||
final p = widget.prompt;
|
||||
final canRemember = p.permissionSuggestions.isNotEmpty;
|
||||
@@ -245,6 +269,11 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
ClideButton(label: '1. Allow', variant: ClideButtonVariant.primary, onPressed: () => _permAllow()),
|
||||
if (canRemember) ClideButton(label: "2. Allow & don't ask again", onPressed: () => _permAllow(remember: true)),
|
||||
ClideButton(label: '${canRemember ? '3' : '2'}. Deny', onPressed: _permDeny),
|
||||
ClideButton(
|
||||
label: '${canRemember ? '4' : '3'}. Deny & simplify',
|
||||
tooltip: 'Deny and ask Claude to retry this action in a simpler format — complex interactions don\'t work well with the permission system.',
|
||||
onPressed: _permDenySimplify,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -267,11 +296,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
[
|
||||
if (_q.isNotEmpty) _questionBody(tokens, 0),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -284,11 +315,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
const SizedBox(height: 8),
|
||||
for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step = _q.length - 1)),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -302,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
const SizedBox(height: 10),
|
||||
_questionBody(tokens, _step),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
if (_step > 0) ...[
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step--)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ClideButton(
|
||||
label: last ? 'Review ›' : 'Next ›',
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: answered ? () => setState(() => _step++) : null,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (_step > 0) ...[ClideButton(label: '‹ Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
|
||||
ClideButton(label: last ? 'Review ›' : 'Next ›', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -326,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
final done = _answer(i).isNotEmpty;
|
||||
final text = '${i + 1} · $head${done ? ' ✓' : ''}';
|
||||
if (i == _step) {
|
||||
chips.add(Container(
|
||||
chips.add(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18),
|
||||
border: Border.all(color: tokens.statusInfo),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
));
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
||||
}
|
||||
@@ -348,8 +381,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 110, child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
|
||||
Expanded(child: ClideText('→ ${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground)),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText('→ ${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -375,10 +413,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
_optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
|
||||
],
|
||||
),
|
||||
if (hasOther) ...[
|
||||
const SizedBox(height: 8),
|
||||
_NoteField(controller: _other[qi], placeholder: 'type your answer…'),
|
||||
],
|
||||
if (hasOther) ...[const SizedBox(height: 8), _NoteField(controller: _other[qi], placeholder: 'type your answer…')],
|
||||
const SizedBox(height: 8),
|
||||
_NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'),
|
||||
],
|
||||
@@ -471,10 +506,7 @@ Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic>
|
||||
/// / timeout annotations.
|
||||
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
final cmd = (input['command'] as String? ?? '').trimRight();
|
||||
final notes = <String>[
|
||||
if (input['run_in_background'] == true) 'background',
|
||||
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
|
||||
];
|
||||
final notes = <String>[if (input['run_in_background'] == true) 'background', if (input['timeout'] is num) 'timeout ${input['timeout']}ms'];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -536,19 +568,14 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
|
||||
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
||||
}
|
||||
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
||||
return ClideText(
|
||||
label.isNotEmpty ? label : toolName,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.globalForeground,
|
||||
);
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
|
||||
}
|
||||
|
||||
/// A muted file path line, shared across tool bodies.
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
);
|
||||
);
|
||||
|
||||
// -- shared note / free-text field -------------------------------------------
|
||||
|
||||
@@ -585,7 +612,7 @@ class _NoteFieldState extends State<_NoteField> {
|
||||
children: [
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (_, v, __) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
|
||||
builder: (_, v, _) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
|
||||
),
|
||||
EditableText(
|
||||
controller: widget.controller,
|
||||
@@ -624,14 +651,9 @@ List<_Question> _parseQuestions(Map<String, dynamic> input) {
|
||||
return [
|
||||
for (final q in raw)
|
||||
if (q is Map)
|
||||
_Question(
|
||||
q['question'] as String? ?? '',
|
||||
q['header'] as String? ?? '',
|
||||
q['multiSelect'] as bool? ?? false,
|
||||
[
|
||||
_Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
|
||||
for (final o in (q['options'] as List? ?? const []))
|
||||
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
|
||||
],
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,13 +13,7 @@ import 'dart:io';
|
||||
|
||||
/// One session in the workspace, summarised for the picker.
|
||||
class SessionSummary {
|
||||
const SessionSummary({
|
||||
required this.id,
|
||||
required this.modified,
|
||||
this.firstUser,
|
||||
this.lastUser,
|
||||
this.sizeBytes = 0,
|
||||
});
|
||||
const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
|
||||
|
||||
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
|
||||
final String id;
|
||||
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
|
||||
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
|
||||
/// capped at [max]. Each is summarised by bookend user prompts read from a
|
||||
/// bounded [window] at each end of its transcript.
|
||||
Future<List<SessionSummary>> listSessions(
|
||||
Directory dir, {
|
||||
int max = 20,
|
||||
int window = 128 * 1024,
|
||||
}) async {
|
||||
Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
|
||||
if (!await dir.exists()) return const [];
|
||||
final files = <File>[];
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
|
||||
final stat = await f.stat();
|
||||
final bookends = await _bookends(f, window);
|
||||
final id = _sessionId(f.path);
|
||||
summaries.add(SessionSummary(
|
||||
summaries.add(
|
||||
SessionSummary(
|
||||
id: id,
|
||||
modified: stat.modified,
|
||||
firstUser: bookends.first,
|
||||
lastUser: bookends.last,
|
||||
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
summaries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return summaries.length > max ? summaries.sublist(0, max) : summaries;
|
||||
|
||||
@@ -32,11 +32,7 @@ const _resumeTailBytes = 256 * 1024;
|
||||
|
||||
/// Creates the subprocess for a session — production uses
|
||||
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
|
||||
typedef ProcessFactory = Future<StreamJsonProcess> Function({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
});
|
||||
typedef ProcessFactory = Future<StreamJsonProcess> Function({required List<String> sessionArgs, required String cwd, Map<String, String>? env});
|
||||
|
||||
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
|
||||
/// `teammate:tyre`); [sessionId] is claude's `--session-id`.
|
||||
@@ -150,10 +146,7 @@ ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(
|
||||
broker: broker,
|
||||
sessionResolver: (name) => byMemberName(name)?.session,
|
||||
);
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
final ProcessFactory _factory;
|
||||
@@ -195,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;
|
||||
@@ -224,18 +238,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
...bootstrap.extraArgs,
|
||||
...sessionArgs,
|
||||
];
|
||||
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
|
||||
|
||||
final proc = await _factory(
|
||||
sessionArgs: sessionArgs,
|
||||
cwd: spec.cwd,
|
||||
env: bootstrap.envDelta,
|
||||
);
|
||||
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
||||
final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
|
||||
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose);
|
||||
@@ -362,7 +367,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
|
||||
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
|
||||
static String _teamSystemPrompt(String name, String role) =>
|
||||
'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
|
||||
'Coordinate with teammates using the clide-team MCP tools: '
|
||||
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, '
|
||||
'list_teammates() to see the roster, inbox() to read messages sent to you, and '
|
||||
|
||||
@@ -12,12 +12,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SessionPickerDialog extends StatefulWidget {
|
||||
const SessionPickerDialog({
|
||||
super.key,
|
||||
required this.sessions,
|
||||
required this.onPick,
|
||||
required this.onCancel,
|
||||
});
|
||||
const SessionPickerDialog({super.key, required this.sessions, required this.onPick, required this.onCancel});
|
||||
|
||||
final List<SessionSummary> sessions;
|
||||
final void Function(String id) onPick;
|
||||
@@ -92,11 +87,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.sessions.length,
|
||||
itemBuilder: (ctx, i) => _row(theme, i),
|
||||
),
|
||||
child: ListView.builder(shrinkWrap: true, itemCount: widget.sessions.length, itemBuilder: (ctx, i) => _row(theme, i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -117,13 +108,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
s.label,
|
||||
fontSize: clideFontSmall,
|
||||
color: theme.globalForeground,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall),
|
||||
],
|
||||
|
||||
@@ -17,13 +17,7 @@ import 'package:flutter/widgets.dart';
|
||||
typedef SessionDeleter = Future<void> Function(Directory dir, String id);
|
||||
|
||||
class SessionStorageDialog extends StatefulWidget {
|
||||
const SessionStorageDialog({
|
||||
super.key,
|
||||
required this.dir,
|
||||
required this.sessions,
|
||||
required this.onClose,
|
||||
this.deleter = deleteSession,
|
||||
});
|
||||
const SessionStorageDialog({super.key, required this.dir, required this.sessions, required this.onClose, this.deleter = deleteSession});
|
||||
|
||||
final Directory dir;
|
||||
final List<SessionSummary> sessions;
|
||||
@@ -79,19 +73,11 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
|
||||
child: ClideText(
|
||||
'Session storage · ${formatBytes(_total)} total',
|
||||
fontSize: clideFontBody,
|
||||
color: theme.globalForeground,
|
||||
),
|
||||
child: ClideText('Session storage · ${formatBytes(_total)} total', fontSize: clideFontBody, color: theme.globalForeground),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: ClideText(
|
||||
'Deleting a session you are currently using will break that pane.',
|
||||
muted: true,
|
||||
fontSize: clideFontSmall,
|
||||
),
|
||||
child: ClideText('Deleting a session you are currently using will break that pane.', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
if (_sessions.isEmpty)
|
||||
Padding(
|
||||
@@ -100,11 +86,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (ctx, i) => _row(theme, _sessions[i]),
|
||||
),
|
||||
child: ListView.builder(shrinkWrap: true, itemCount: _sessions.length, itemBuilder: (ctx, i) => _row(theme, _sessions[i])),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,21 +33,47 @@ abstract class StreamJsonProcess {
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> kill();
|
||||
|
||||
/// The last lines of the child's stderr, drained continuously so the pipe
|
||||
/// can never fill and block the child mid-turn (T-361). Default: none.
|
||||
List<String> get stderrTail => const [];
|
||||
|
||||
/// Completes with the child's exit code, or null when there is no real
|
||||
/// process to watch (fakes that never "exit").
|
||||
Future<int>? get exitCode => null;
|
||||
}
|
||||
|
||||
/// A bounded FIFO of the most recent lines — the stderr tail kept for
|
||||
/// post-mortem diagnostics while the stream itself is drained and dropped.
|
||||
class BoundedLineBuffer {
|
||||
BoundedLineBuffer({this.cap = 100});
|
||||
|
||||
final int cap;
|
||||
final List<String> _lines = [];
|
||||
|
||||
void add(String line) {
|
||||
_lines.add(line);
|
||||
if (_lines.length > cap) _lines.removeAt(0);
|
||||
}
|
||||
|
||||
List<String> get lines => List.unmodifiable(_lines);
|
||||
}
|
||||
|
||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc);
|
||||
class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc) {
|
||||
// Drain stderr from the moment the process exists — with --verbose the
|
||||
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
|
||||
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
|
||||
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
|
||||
}
|
||||
|
||||
final Process _proc;
|
||||
final BoundedLineBuffer _stderr = BoundedLineBuffer();
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
static Future<ClaudeStreamJsonProcess> start({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
}) async {
|
||||
static Future<ClaudeStreamJsonProcess> start({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) async {
|
||||
final proc = await Process.start(
|
||||
'claude',
|
||||
[
|
||||
@@ -79,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
|
||||
@@ -183,15 +218,30 @@ final class AllowTool extends ToolDecision {
|
||||
}
|
||||
|
||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||
///
|
||||
/// [quiet] marks a deliberate, user-initiated denial that the user already
|
||||
/// understands (e.g. "Deny & simplify", T-340) — the resulting error tool-result
|
||||
/// should fold to a muted card rather than shout as a red failure. Off by
|
||||
/// default, so a genuine/unexpected denial still renders prominently.
|
||||
final class DenyTool extends ToolDecision {
|
||||
const DenyTool(this.message);
|
||||
const DenyTool(this.message, {this.quiet = false});
|
||||
final String message;
|
||||
final bool quiet;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -202,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();
|
||||
@@ -235,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.
|
||||
@@ -246,14 +298,21 @@ class StreamJsonSession {
|
||||
/// green/red border (D-78).
|
||||
final _toolUseOutcome = <String, bool>{};
|
||||
|
||||
/// tool_use_ids whose error result should render folded + muted rather than as
|
||||
/// a loud red failure (T-340): expected, user-initiated denials (Deny &
|
||||
/// simplify, today) that the user already understands. The reusable extension
|
||||
/// point — add an id here at the moment you know its error is non-alarming.
|
||||
final _quietErrorToolUses = <String>{};
|
||||
|
||||
/// Read-only views for the conversation view.
|
||||
Set<String> get promptedToolUseIds => _promptedToolUses;
|
||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -290,14 +349,31 @@ 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.
|
||||
if (_mcpServers.isNotEmpty) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
@@ -305,7 +381,8 @@ class StreamJsonSession {
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +512,8 @@ class StreamJsonSession {
|
||||
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
||||
final tuid = request['tool_use_id'] as String? ?? '';
|
||||
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
|
||||
_queue.add(ToolPrompt(
|
||||
_queue.add(
|
||||
ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
@@ -443,7 +521,8 @@ class StreamJsonSession {
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
|
||||
));
|
||||
),
|
||||
);
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
}
|
||||
@@ -452,10 +531,12 @@ class StreamJsonSession {
|
||||
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
|
||||
return;
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
|
||||
@@ -476,14 +557,16 @@ class StreamJsonSession {
|
||||
} else {
|
||||
mcpResponse = await _dispatchMcp(server, message);
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {
|
||||
'subtype': 'success',
|
||||
'request_id': rid,
|
||||
'response': {'mcp_response': mcpResponse}
|
||||
'response': {'mcp_response': mcpResponse},
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
McpServer? _mcpServerNamed(String? name) {
|
||||
@@ -542,12 +625,25 @@ class StreamJsonSession {
|
||||
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
||||
if (idx < 0) return; // unknown / already resolved
|
||||
final prompt = _queue.removeAt(idx);
|
||||
if (prompt.toolUseId.isNotEmpty) _toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
|
||||
_proc.writeLine(jsonEncode({
|
||||
if (prompt.toolUseId.isNotEmpty) {
|
||||
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
|
||||
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
|
||||
}
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
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) {
|
||||
@@ -636,16 +732,13 @@ class StreamJsonSession {
|
||||
/// so it renders immediately (stream-json doesn't replay stdin without
|
||||
/// `--replay-user-messages`).
|
||||
void send(String text) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'user',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
}));
|
||||
_items.add(UserMessage(
|
||||
uuid: 'local-${_localSeq++}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
text: text,
|
||||
));
|
||||
}),
|
||||
);
|
||||
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
@@ -653,11 +746,13 @@ class StreamJsonSession {
|
||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||
void interrupt() {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'interrupt-${_localSeq++}',
|
||||
'request': {'subtype': 'interrupt'},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the session's permission mode (T-181, D-77). Sends a
|
||||
@@ -669,11 +764,13 @@ class StreamJsonSession {
|
||||
/// cockpit badge's plain click; bypassPermissions is reachable only via a
|
||||
/// confirmed shift-click (T-181).
|
||||
void setPermissionMode(String mode) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'set-perm-${_localSeq++}',
|
||||
'request': {'subtype': 'set_permission_mode', 'mode': mode},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
// Optimistically reflect the change so the badge / status line update
|
||||
// immediately (T-250) — the control_request emits no status event, and a
|
||||
// fresh system/init only arrives later. The next init reconciles if the
|
||||
@@ -681,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();
|
||||
@@ -689,5 +802,6 @@ class StreamJsonSession {
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
await _endCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/// Claude's working task list, modelled from the conversation for the docked
|
||||
/// task view (T-308).
|
||||
///
|
||||
/// Claude tracks tasks with the `TodoWrite` tool, which **replaces the whole
|
||||
/// list** on each call — so the current state is simply the todos of the most
|
||||
/// recent `TodoWrite`. This is a latest-wins snapshot, not an append log; older
|
||||
/// `TodoWrite` calls are superseded.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
|
||||
enum TaskStatus { pending, inProgress, completed }
|
||||
|
||||
class TaskItem {
|
||||
const TaskItem({required this.text, required this.status});
|
||||
|
||||
final String text;
|
||||
final TaskStatus status;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is TaskItem && other.text == text && other.status == status;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(text, status);
|
||||
}
|
||||
|
||||
/// The current task list — the todos of the most recent `TodoWrite` tool call,
|
||||
/// or empty if Claude hasn't written one this session.
|
||||
List<TaskItem> taskListFrom(List<ConversationItem> items) {
|
||||
for (var i = items.length - 1; i >= 0; i--) {
|
||||
final it = items[i];
|
||||
if (it is! AssistantToolUse || it.name != 'TodoWrite') continue;
|
||||
final raw = it.input['todos'];
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final t in raw)
|
||||
if (t is Map)
|
||||
TaskItem(
|
||||
// `content` is the canonical label; `activeForm` is the present-tense
|
||||
// variant TodoWrite also carries — fall back to it, then to empty.
|
||||
text: (t['content'] ?? t['activeForm'] ?? '').toString(),
|
||||
status: _statusFrom(t['status']),
|
||||
),
|
||||
];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
TaskStatus _statusFrom(Object? raw) => switch (raw) {
|
||||
'in_progress' => TaskStatus.inProgress,
|
||||
'completed' => TaskStatus.completed,
|
||||
_ => TaskStatus.pending,
|
||||
};
|
||||
@@ -35,13 +35,7 @@ class TeamMemberRef {
|
||||
|
||||
/// A message left for a member, in arrival order.
|
||||
class TeamMessage {
|
||||
const TeamMessage({
|
||||
required this.from,
|
||||
required this.text,
|
||||
required this.at,
|
||||
this.to,
|
||||
this.broadcast = false,
|
||||
});
|
||||
const TeamMessage({required this.from, required this.text, required this.at, this.to, this.broadcast = false});
|
||||
final String from;
|
||||
|
||||
/// Recipient name: a single member's display name (direct message), `null`
|
||||
@@ -52,13 +46,7 @@ class TeamMessage {
|
||||
final DateTime at;
|
||||
final bool broadcast;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'from': from,
|
||||
if (to != null) 'to': to,
|
||||
'text': text,
|
||||
'at': at.toIso8601String(),
|
||||
if (broadcast) 'broadcast': true,
|
||||
};
|
||||
Map<String, dynamic> toJson() => {'from': from, if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true};
|
||||
}
|
||||
|
||||
/// A shared task. Status is one of `open` / `claimed` / `done`.
|
||||
@@ -69,12 +57,7 @@ class TeamTask {
|
||||
String status;
|
||||
String? owner;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'status': status,
|
||||
if (owner != null) 'owner': owner,
|
||||
};
|
||||
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'status': status, if (owner != null) 'owner': owner};
|
||||
}
|
||||
|
||||
/// Pushes [text] into the member identified by [toMemberId] as a user message
|
||||
@@ -304,7 +287,7 @@ class TeamBroker {
|
||||
return {'ok': true, 'task': t.toJson()};
|
||||
}
|
||||
return {
|
||||
'tasks': [for (final t in _tasks.values) t.toJson()]
|
||||
'tasks': [for (final t in _tasks.values) t.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,7 +345,8 @@ class TeamMcpServer implements McpServer {
|
||||
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
|
||||
case 'task_status':
|
||||
return _result(
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
|
||||
);
|
||||
default:
|
||||
return _error('Unknown team tool: $name');
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ typedef SessionResolver = StreamJsonSession? Function(String memberName);
|
||||
/// Lifetime matches the orchestrator: created once, subscribed to the broker,
|
||||
/// disposed when the orchestrator is torn down.
|
||||
class TeamChatModel {
|
||||
TeamChatModel({
|
||||
required TeamBroker broker,
|
||||
SessionResolver? sessionResolver,
|
||||
}) : _broker = broker,
|
||||
_sessionResolver = sessionResolver {
|
||||
TeamChatModel({required TeamBroker broker, SessionResolver? sessionResolver}) : _broker = broker, _sessionResolver = sessionResolver {
|
||||
_sub = broker.messages.listen(_onMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,7 @@ import 'package:flutter/widgets.dart';
|
||||
/// [onPopOut] is called when the user taps the pop-out icon to open the full
|
||||
/// pane — the extension wires this to `panels.activateTab`.
|
||||
class TeamChatSidebar extends StatefulWidget {
|
||||
const TeamChatSidebar({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.broker,
|
||||
required this.onPopOut,
|
||||
});
|
||||
const TeamChatSidebar({super.key, required this.model, required this.broker, required this.onPopOut});
|
||||
|
||||
final TeamChatModel model;
|
||||
final TeamBroker broker;
|
||||
@@ -149,11 +144,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
onTap: widget.onPopOut,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
|
||||
child: ClideIcon(
|
||||
PhosphorIcons.byName('arrows-out-simple'),
|
||||
size: 10,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -177,13 +168,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
formatLabel: (n) => '@$n',
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _ChatInputField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
tokens: tokens,
|
||||
onSubmit: _submit,
|
||||
placeholder: '@name or @team …',
|
||||
),
|
||||
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -200,11 +185,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
/// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the
|
||||
/// interrupt tickbox and full @-completion.
|
||||
class TeamChatPane extends StatefulWidget {
|
||||
const TeamChatPane({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.broker,
|
||||
});
|
||||
const TeamChatPane({super.key, required this.model, required this.broker});
|
||||
|
||||
final TeamChatModel model;
|
||||
final TeamBroker broker;
|
||||
@@ -231,11 +212,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
// Scroll to bottom on new message.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -297,11 +274,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
final text = raw.trim();
|
||||
if (text.isEmpty) return;
|
||||
final parsed = parseAtTag(text);
|
||||
widget.model.postAsUser(
|
||||
parsed.body.isEmpty ? text : parsed.body,
|
||||
toName: parsed.recipient,
|
||||
interrupt: _interrupt,
|
||||
);
|
||||
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient, interrupt: _interrupt);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@@ -340,11 +313,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (_, i) => _ChatRow(
|
||||
key: ValueKey(messages[i].at.microsecondsSinceEpoch),
|
||||
message: messages[i],
|
||||
tokens: tokens,
|
||||
),
|
||||
itemBuilder: (_, i) => _ChatRow(key: ValueKey(messages[i].at.microsecondsSinceEpoch), message: messages[i], tokens: tokens),
|
||||
),
|
||||
),
|
||||
// Composer + interrupt tickbox.
|
||||
@@ -375,24 +344,13 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
margin: const EdgeInsets.only(right: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000),
|
||||
border: Border.all(
|
||||
color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, width: 1),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: _interrupt
|
||||
? Center(
|
||||
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
|
||||
)
|
||||
: null,
|
||||
child: _interrupt ? Center(child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus)) : null,
|
||||
),
|
||||
),
|
||||
ClideText(
|
||||
'Interrupt',
|
||||
fontSize: clideFontSmall,
|
||||
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
ClideText('Interrupt', fontSize: clideFontSmall, color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -405,13 +363,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
formatLabel: (n) => '@$n',
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _ChatInputField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
tokens: tokens,
|
||||
onSubmit: _submit,
|
||||
placeholder: '@name or @team …',
|
||||
),
|
||||
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
margin: const EdgeInsets.only(right: 5, top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: senderColor.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
|
||||
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
|
||||
),
|
||||
if (toLabel != null)
|
||||
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
|
||||
|
||||
/// Inline text input for the chat composer.
|
||||
class _ChatInputField extends StatelessWidget {
|
||||
const _ChatInputField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.tokens,
|
||||
required this.onSubmit,
|
||||
required this.placeholder,
|
||||
});
|
||||
const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalForeground,
|
||||
height: 1.4,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
|
||||
@@ -51,10 +51,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
void _onJoined(TeamMemberJoined m) {
|
||||
if (_controllers.containsKey(m.agentId)) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controllers[m.agentId] = ConversationController.fromBus(
|
||||
messages: kernel.messages,
|
||||
channel: ClaudeConversation.teammateChannel(m.agentId),
|
||||
);
|
||||
_controllers[m.agentId] = ConversationController.fromBus(messages: kernel.messages, channel: ClaudeConversation.teammateChannel(m.agentId));
|
||||
setState(() => _members.add(m));
|
||||
}
|
||||
|
||||
@@ -92,11 +89,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeammateGrid(
|
||||
members: _members,
|
||||
controllers: _controllers,
|
||||
tokens: tokens,
|
||||
),
|
||||
child: _TeammateGrid(members: _members, controllers: _controllers, tokens: tokens),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -145,11 +138,7 @@ class _TeammateGrid extends StatelessWidget {
|
||||
children: [
|
||||
for (var r = 0; r < rows; r++)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)),
|
||||
],
|
||||
),
|
||||
child: Row(children: [for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c))]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/// Sidebar "pick up" handling (T-327/T-339): inject a ticket's prompt into the
|
||||
/// active Claude session and, on acceptance, advance the ticket to in_progress.
|
||||
///
|
||||
/// Kept out of `extension.dart` so it's unit-testable without dragging the whole
|
||||
/// (UI-wiring) extension into instrumentation.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Statuses a pick-up may advance from: a not-yet-started ticket. Picking up a
|
||||
/// ticket that's already `in_progress`/`review`/`done`/`cancelled` injects the
|
||||
/// prompt but leaves the status alone, so a re-pick-up never drags it backwards
|
||||
/// or reopens it (T-339).
|
||||
const kPickUpStartableStatuses = {'backlog', 'ready'};
|
||||
|
||||
/// Inject a picked-up ticket's prompt into the active session (the `primary`
|
||||
/// lead, else the first visible one) and, on acceptance from a not-yet-started
|
||||
/// ticket, advance it to `in_progress` and publish a `changed` so the sidebar
|
||||
/// refreshes (T-327/T-339). Returns whether a live session accepted the prompt.
|
||||
///
|
||||
/// With no live session there's no injection and no state change — a quiet
|
||||
/// no-op.
|
||||
Future<bool> applyTicketPickUp(
|
||||
Map<String, Object?> data, {
|
||||
required ClaudeSessionOrchestrator? orchestrator,
|
||||
required DaemonClient ipc,
|
||||
required MessageBus messages,
|
||||
}) async {
|
||||
final prompt = data['prompt'] as String?;
|
||||
if (prompt == null || prompt.isEmpty) return false;
|
||||
final target = orchestrator?.byId('primary') ?? orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return false; // no live session → quiet no-op, no state change
|
||||
orchestrator!.injectMessage(target.id, prompt);
|
||||
|
||||
final id = data['id'] as String?;
|
||||
final status = data['status'] as String?;
|
||||
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) {
|
||||
final resp = await ipc.request(
|
||||
'pql.tickets.status',
|
||||
args: {
|
||||
'ids': [id],
|
||||
'status': 'in_progress',
|
||||
},
|
||||
);
|
||||
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
||||
/// D-75).
|
||||
/// Bus addressing for Claude conversation content (epic T-132, D-75).
|
||||
///
|
||||
/// One reader tails a workspace transcript; this publisher republishes
|
||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
||||
/// panels can then subscribe to the same conversation via the bus instead
|
||||
/// of each owning its own reader — the decoupling the team panels
|
||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
||||
/// tile per teammate.
|
||||
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
|
||||
/// tailing a transcript, republished onto the bus) had no production
|
||||
/// constructor calls after the stream-json pivot (D-77) and was removed
|
||||
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
|
||||
/// constants remain — the meta sidebar and team panel host still consume
|
||||
/// them for member-status messages.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
|
||||
/// Bus addressing for Claude conversation content.
|
||||
abstract final class ClaudeConversation {
|
||||
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
|
||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||
|
||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
||||
/// Key under which the [ConversationItem] travels in a bus message's data.
|
||||
static const itemKey = 'item';
|
||||
|
||||
/// Shared channel carrying each team member's live status (T-157). Every
|
||||
@@ -44,37 +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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import 'dart:isolate';
|
||||
|
||||
/// Discriminated union of conversation items the reader can emit.
|
||||
sealed class ConversationItem {
|
||||
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
|
||||
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid, this.parentToolUseId});
|
||||
|
||||
final String uuid;
|
||||
final DateTime timestamp;
|
||||
@@ -50,6 +50,14 @@ sealed class ConversationItem {
|
||||
/// branches off the assistant message that issued its spawning Agent/Task
|
||||
/// tool-use, so this links the prompt to the right Agent card (T-263).
|
||||
final String? parentUuid;
|
||||
|
||||
/// The `parent_tool_use_id` from the stream-json wire (T-338): the tool-use
|
||||
/// id of the Agent/Task call that spawned this sub-agent message. Stream-json
|
||||
/// tags every sidechain item with it — the transcript JSONL instead uses
|
||||
/// [isSidechain] + [parentUuid]. When present it routes the item straight to
|
||||
/// its Agent card by tool-use id, no uuid-chain walk needed, and on its own
|
||||
/// marks the item as a sidechain message.
|
||||
final String? parentToolUseId;
|
||||
}
|
||||
|
||||
/// A user-typed message (plain text, possibly multi-part).
|
||||
@@ -59,6 +67,7 @@ final class UserMessage extends ConversationItem {
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.text,
|
||||
this.injected = false,
|
||||
});
|
||||
@@ -82,6 +91,7 @@ final class ToolResultMessage extends ConversationItem {
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.toolUseId,
|
||||
required this.content,
|
||||
required this.isError,
|
||||
@@ -102,6 +112,7 @@ final class AssistantTextMessage extends ConversationItem {
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
@@ -118,6 +129,7 @@ final class AssistantThinkingMessage extends ConversationItem {
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.thinking,
|
||||
});
|
||||
|
||||
@@ -134,6 +146,7 @@ final class AssistantToolUse extends ConversationItem {
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.toolUseId,
|
||||
required this.name,
|
||||
required this.input,
|
||||
@@ -158,13 +171,7 @@ final class AssistantToolUse extends ConversationItem {
|
||||
/// driver has already resolved (workspace-relative paths are resolved before
|
||||
/// injection); [caption] is an optional one-line label.
|
||||
final class ImageMessage extends ConversationItem {
|
||||
const ImageMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.path,
|
||||
this.caption,
|
||||
});
|
||||
const ImageMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.path, this.caption});
|
||||
|
||||
/// Absolute path to the image file on disk.
|
||||
final String path;
|
||||
@@ -202,14 +209,7 @@ const _isolateParseThreshold = 64 * 1024;
|
||||
const _knownMajorVersions = {1, 2};
|
||||
|
||||
/// Record types to skip (do not emit as conversation items).
|
||||
const _skipTypes = {
|
||||
'attachment',
|
||||
'system',
|
||||
'last-prompt',
|
||||
'permission-mode',
|
||||
'file-history-snapshot',
|
||||
'queue-operation',
|
||||
};
|
||||
const _skipTypes = {'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'};
|
||||
|
||||
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s.
|
||||
///
|
||||
@@ -426,14 +426,7 @@ class TranscriptReader {
|
||||
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||
/// and the reader [merge]s deltas into a running status.
|
||||
class SessionStatus {
|
||||
const SessionStatus({
|
||||
this.model,
|
||||
this.permissionMode,
|
||||
this.contextTokens,
|
||||
this.cost,
|
||||
this.contextWindow,
|
||||
this.rateLimitInfo,
|
||||
});
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
|
||||
|
||||
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||
final String? model;
|
||||
@@ -537,8 +530,10 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
|
||||
final major = int.tryParse(majorStr);
|
||||
if (major != null && !_knownMajorVersions.contains(major)) {
|
||||
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||
'parsing will degrade gracefully');
|
||||
warnings.add(
|
||||
'unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||
'parsing will degrade gracefully',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,9 +550,14 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
if (_skipTypes.contains(type)) return;
|
||||
|
||||
final uuid = envelope['uuid'] as String? ?? '';
|
||||
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
||||
final rawParent = envelope['parentUuid'] as String?;
|
||||
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
|
||||
// Stream-json tags sub-agent messages with `parent_tool_use_id` (the spawning
|
||||
// Agent/Task tool-use), not the transcript's isSidechain/parentUuid (T-338).
|
||||
// Treat its presence as a sidechain marker so the fold + de-emphasis kick in.
|
||||
final rawParentTool = envelope['parent_tool_use_id'] as String?;
|
||||
final parentToolUseId = (rawParentTool != null && rawParentTool.isNotEmpty) ? rawParentTool : null;
|
||||
final isSidechain = (envelope['isSidechain'] as bool? ?? false) || parentToolUseId != null;
|
||||
|
||||
DateTime timestamp;
|
||||
try {
|
||||
@@ -568,9 +568,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
|
||||
switch (type) {
|
||||
case 'user':
|
||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
|
||||
case 'assistant':
|
||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, parentToolUseId, out);
|
||||
_extractAssistantStatus(envelope, status);
|
||||
default:
|
||||
break; // unknown type — degrade gracefully
|
||||
@@ -596,6 +596,7 @@ void _parseUserInto(
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
String? parentUuid,
|
||||
String? parentToolUseId,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
@@ -609,7 +610,17 @@ void _parseUserInto(
|
||||
|
||||
if (content is String) {
|
||||
if (content.isNotEmpty) {
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
|
||||
out.add(
|
||||
UserMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: content,
|
||||
injected: injected,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -624,15 +635,18 @@ void _parseUserInto(
|
||||
if (text.isNotEmpty) textParts.add(text);
|
||||
case 'tool_result':
|
||||
final rawContent = item['content'];
|
||||
out.add(ToolResultMessage(
|
||||
out.add(
|
||||
ToolResultMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||
isError: item['is_error'] as bool? ?? false,
|
||||
));
|
||||
),
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -648,6 +662,7 @@ void _parseAssistantInto(
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
String? parentUuid,
|
||||
String? parentToolUseId,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
@@ -661,24 +676,45 @@ void _parseAssistantInto(
|
||||
case 'text':
|
||||
final text = item['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) {
|
||||
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
|
||||
}
|
||||
case 'thinking':
|
||||
final thinking = item['thinking'] as String? ?? '';
|
||||
if (thinking.isNotEmpty) {
|
||||
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
|
||||
}
|
||||
case 'tool_use':
|
||||
final rawInput = item['input'];
|
||||
out.add(AssistantToolUse(
|
||||
out.add(
|
||||
AssistantTextMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
case 'thinking':
|
||||
final thinking = item['thinking'] as String? ?? '';
|
||||
if (thinking.isNotEmpty) {
|
||||
out.add(
|
||||
AssistantThinkingMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
thinking: thinking,
|
||||
),
|
||||
);
|
||||
}
|
||||
case 'tool_use':
|
||||
final rawInput = item['input'];
|
||||
out.add(
|
||||
AssistantToolUse(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
toolUseId: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||
));
|
||||
),
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -70,19 +70,12 @@ class CliInstallExtension extends ClideExtension {
|
||||
final ctx = _ctx;
|
||||
if (r.ok) {
|
||||
ctx?.notify.success(r.message, title: 'clide CLI installed');
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'installed': r.installedPath,
|
||||
'onPath': r.onPath,
|
||||
});
|
||||
return IpcResponse.ok(id: '', data: {'installed': r.installedPath, 'onPath': r.onPath});
|
||||
}
|
||||
ctx?.notify.error(r.message, title: 'clide CLI install failed');
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: r.message,
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class DecisionTypeColors {
|
||||
const DecisionTypeColors({
|
||||
required this.confirmed,
|
||||
required this.question,
|
||||
required this.rejected,
|
||||
});
|
||||
const DecisionTypeColors({required this.confirmed, required this.question, required this.rejected});
|
||||
|
||||
final Color confirmed;
|
||||
final Color question;
|
||||
@@ -18,17 +14,9 @@ class DecisionTypeColors {
|
||||
_ => confirmed,
|
||||
};
|
||||
|
||||
static const dark = DecisionTypeColors(
|
||||
confirmed: Color(0xFF7DD3A8),
|
||||
question: Color(0xFFE6C370),
|
||||
rejected: Color(0xFFE87D7D),
|
||||
);
|
||||
static const dark = DecisionTypeColors(confirmed: Color(0xFF7DD3A8), question: Color(0xFFE6C370), rejected: Color(0xFFE87D7D));
|
||||
|
||||
static const light = DecisionTypeColors(
|
||||
confirmed: Color(0xFF1D7A4E),
|
||||
question: Color(0xFFB08A20),
|
||||
rejected: Color(0xFFC03030),
|
||||
);
|
||||
static const light = DecisionTypeColors(confirmed: Color(0xFF1D7A4E), question: Color(0xFFB08A20), rejected: Color(0xFFC03030));
|
||||
|
||||
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
|
||||
}
|
||||
|
||||
@@ -110,10 +110,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
return ClidePaneChrome(
|
||||
title: id,
|
||||
subtitle: title,
|
||||
leading: ReaderPinButton(
|
||||
pinned: _nav?.hasPinned ?? false,
|
||||
onTap: _decision != null ? _onPin : null,
|
||||
),
|
||||
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _decision != null ? _onPin : null),
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
@@ -144,7 +141,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: type ?? 'confirmed',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
@@ -159,21 +160,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (status != null && status != 'active') ...[
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(status: status, tokens: tokens),
|
||||
],
|
||||
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
|
||||
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (body != null && body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
],
|
||||
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
|
||||
@@ -23,6 +23,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<DaemonEvent>? _fileSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
bool _refreshing = false;
|
||||
bool _pendingRefresh = false;
|
||||
|
||||
@@ -37,6 +38,12 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
|
||||
.listen((_) => _refresh());
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
// The first load can fire before the project's workspace is wired into
|
||||
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
|
||||
// runs against the wrong/old DB and the list errors. Re-fetch once the
|
||||
// workspace is actually open — ProjectOpened fires after the IPC server
|
||||
// swaps to the project workRoot. (T-352)
|
||||
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
|
||||
}
|
||||
if (!_loading || _decisions.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
@@ -88,6 +95,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
_focusSub?.cancel();
|
||||
_fileSub?.cancel();
|
||||
_schedulerSub?.cancel();
|
||||
_projectSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -131,11 +139,13 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter
|
||||
? _decisions
|
||||
.where((d) =>
|
||||
.where(
|
||||
(d) =>
|
||||
d.id.toLowerCase().contains(lf) ||
|
||||
d.title.toLowerCase().contains(lf) ||
|
||||
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||
(d.type ?? '').contains(lf))
|
||||
(d.type ?? '').contains(lf),
|
||||
)
|
||||
.toList()
|
||||
: _decisions;
|
||||
|
||||
@@ -150,7 +160,9 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Expanded(
|
||||
child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
@@ -172,39 +184,66 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
ClideAccordion(
|
||||
label: 'CONFIRMED',
|
||||
count: confirmed.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [
|
||||
for (final d in confirmed)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (questions.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'QUESTIONS',
|
||||
count: questions.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [
|
||||
for (final d in questions)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (rejected.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'REJECTED',
|
||||
count: rejected.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [
|
||||
for (final d in rejected)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -263,7 +302,11 @@ class _DecisionCard extends StatelessWidget {
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: entry.type ?? 'confirmed',
|
||||
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
|
||||
@@ -25,12 +25,7 @@ class DeepLinkExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'deeplink.invoke',
|
||||
command: 'deeplink.invoke',
|
||||
title: 'Open a clide:// deep link',
|
||||
run: _invoke,
|
||||
),
|
||||
CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -16,19 +16,8 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
_preset ?? classicPreset(),
|
||||
CommandContribution(
|
||||
id: 'layout.reset',
|
||||
command: 'layout.reset',
|
||||
title: 'Layout: Reset to Classic',
|
||||
run: _reset,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'palette.toggle',
|
||||
command: 'palette.toggle',
|
||||
title: 'Command Palette',
|
||||
defaultBinding: 'ctrl+shift+p',
|
||||
run: _togglePalette,
|
||||
),
|
||||
CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset),
|
||||
CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette),
|
||||
// Collapse toggles (D-051, D-054)
|
||||
CommandContribution(
|
||||
id: 'sidebar.collapse',
|
||||
@@ -45,35 +34,11 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
run: _collapseContext,
|
||||
),
|
||||
// Panel focus (D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focus.left',
|
||||
command: 'panel.focus.left',
|
||||
title: 'Focus Left Panel',
|
||||
defaultBinding: 'ctrl+1',
|
||||
run: _focusLeft,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.middle',
|
||||
command: 'panel.focus.middle',
|
||||
title: 'Focus Middle Panel',
|
||||
defaultBinding: 'ctrl+2',
|
||||
run: _focusMiddle,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.right',
|
||||
command: 'panel.focus.right',
|
||||
title: 'Focus Right Panel',
|
||||
defaultBinding: 'ctrl+3',
|
||||
run: _focusRight,
|
||||
),
|
||||
CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
|
||||
CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
|
||||
CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
|
||||
// Focus mode (D-052, D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode',
|
||||
command: 'panel.focusMode',
|
||||
title: 'Toggle Focus Mode',
|
||||
defaultBinding: 'ctrl+.',
|
||||
run: _toggleFocusMode,
|
||||
),
|
||||
CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode.exit',
|
||||
command: 'panel.focusMode.exit',
|
||||
@@ -86,20 +51,8 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
run: _exitFocusMode,
|
||||
),
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(
|
||||
id: 'editor.open',
|
||||
command: 'editor.open',
|
||||
title: 'Open Editor',
|
||||
defaultBinding: 'ctrl+e',
|
||||
run: _openEditor,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'editor.close',
|
||||
command: 'editor.close',
|
||||
title: 'Close Editor',
|
||||
defaultBinding: 'ctrl+w',
|
||||
run: _closeEditor,
|
||||
),
|
||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
||||
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -314,10 +267,6 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
|
||||
static IpcResponse _notActivated() => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'not activated',
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'not activated'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,18 +50,12 @@ class DiffController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Load diffs. Optionally filter to [paths] and toggle [staged].
|
||||
Future<void> load({
|
||||
bool staged = false,
|
||||
List<String> paths = const [],
|
||||
}) async {
|
||||
Future<void> load({bool staged = false, List<String> paths = const []}) async {
|
||||
_staged = staged;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.diff', args: {
|
||||
'staged': staged,
|
||||
if (paths.isNotEmpty) 'paths': paths,
|
||||
});
|
||||
final r = await ipc.request('git.diff', args: {'staged': staged, if (paths.isNotEmpty) 'paths': paths});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -98,24 +98,11 @@ class _DiffViewState extends State<DiffView> {
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
child: ClideText(c.error!, color: tokens.statusError),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (!c.loading && c.diffs.isEmpty && c.error == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
@@ -163,11 +150,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
label: 'show unstaged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? controller.toggleStaged : null,
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -177,11 +160,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
label: 'show staged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? null : controller.toggleStaged,
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -233,11 +212,7 @@ class _FileDiff extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
path,
|
||||
fontSize: clideFontCaption,
|
||||
color: focused ? tokens.globalFocus : tokens.panelHeaderForeground,
|
||||
),
|
||||
child: ClideText(path, fontSize: clideFontCaption, color: focused ? tokens.globalFocus : tokens.panelHeaderForeground),
|
||||
),
|
||||
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
|
||||
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
@@ -250,12 +225,7 @@ class _FileDiff extends StatelessWidget {
|
||||
child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
if (!isBinary)
|
||||
for (final hunk in hunks)
|
||||
_HunkView(
|
||||
hunk: (hunk as Map).cast<String, Object?>(),
|
||||
filePath: path,
|
||||
controller: controller,
|
||||
),
|
||||
for (final hunk in hunks) _HunkView(hunk: (hunk as Map).cast<String, Object?>(), filePath: path, controller: controller),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
@@ -263,11 +233,7 @@ class _FileDiff extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({
|
||||
required this.hunk,
|
||||
required this.filePath,
|
||||
required this.controller,
|
||||
});
|
||||
const _HunkView({required this.hunk, required this.filePath, required this.controller});
|
||||
|
||||
final Map<String, Object?> hunk;
|
||||
final String filePath;
|
||||
@@ -284,17 +250,9 @@ class _HunkView extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(
|
||||
header,
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
for (final lineObj in lines)
|
||||
_DiffLineRow(
|
||||
line: (lineObj as Map).cast<String, Object?>(),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
),
|
||||
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -313,18 +271,9 @@ class _DiffLineRow extends StatelessWidget {
|
||||
final newLineNo = line['newLineNo'] as num?;
|
||||
|
||||
final (Color bg, Color fg) = switch (kind) {
|
||||
'addition' => (
|
||||
tokens.statusSuccess.withValues(alpha: 0.15),
|
||||
tokens.statusSuccess,
|
||||
),
|
||||
'removal' => (
|
||||
tokens.statusError.withValues(alpha: 0.15),
|
||||
tokens.statusError,
|
||||
),
|
||||
_ => (
|
||||
const Color(0x00000000),
|
||||
tokens.globalForeground,
|
||||
),
|
||||
'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
|
||||
'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
|
||||
_ => (const Color(0x00000000), tokens.globalForeground),
|
||||
};
|
||||
|
||||
final prefix = switch (kind) {
|
||||
@@ -361,22 +310,10 @@ class _DiffLineRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
prefix,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -99,12 +99,7 @@ class EditorController extends ChangeNotifier {
|
||||
if (raw is! List) return;
|
||||
_buffers = [
|
||||
for (final b in raw)
|
||||
if (b is Map)
|
||||
(
|
||||
id: b['id']! as String,
|
||||
path: b['path']! as String,
|
||||
dirty: (b['dirty'] as bool?) ?? false,
|
||||
),
|
||||
if (b is Map) (id: b['id']! as String, path: b['path']! as String, dirty: (b['dirty'] as bool?) ?? false),
|
||||
];
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -143,10 +138,7 @@ class EditorController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Called by the widget on every local text edit.
|
||||
void pushLocalEdit({
|
||||
required String newContent,
|
||||
required Selection newSelection,
|
||||
}) {
|
||||
void pushLocalEdit({required String newContent, required Selection newSelection}) {
|
||||
final id = _activeId;
|
||||
if (id == null) return;
|
||||
|
||||
@@ -162,11 +154,7 @@ class EditorController extends ChangeNotifier {
|
||||
// large buffers, so event broadcasts stay small.
|
||||
_pendingLocalEdits++;
|
||||
_suppressNextRemoteEdit = true;
|
||||
ipc.request('editor.set-content', args: {
|
||||
'id': id,
|
||||
'text': newContent,
|
||||
'selection': newSelection.toJson(),
|
||||
}).whenComplete(() => _pendingLocalEdits--);
|
||||
ipc.request('editor.set-content', args: {'id': id, 'text': newContent, 'selection': newSelection.toJson()}).whenComplete(() => _pendingLocalEdits--);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
|
||||
@@ -65,10 +65,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
|
||||
_keymap = kernel.keymap;
|
||||
_matcher = SequenceMatcher(
|
||||
keymap: () => kernel.keymap.keymap ?? Keymap(const []),
|
||||
context: () => kernel.keymap.scope,
|
||||
);
|
||||
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
|
||||
// Rebuild when the Vim mode flips so the editor toggles read-only.
|
||||
kernel.keymap.addListener(_onModeChanged);
|
||||
unawaited(_controller!.hydrate());
|
||||
@@ -105,10 +102,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
_text.updatePath(c.activePath);
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
final sel = TextSelection(
|
||||
baseOffset: c.selection.start.clamp(0, c.content.length),
|
||||
extentOffset: c.selection.end.clamp(0, c.content.length),
|
||||
);
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
@@ -306,9 +300,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
return ClidePaneChrome(
|
||||
title: 'editor',
|
||||
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
|
||||
child: const Center(
|
||||
child: ClideText('Open a file to begin editing.', muted: true),
|
||||
),
|
||||
child: const Center(child: ClideText('Open a file to begin editing.', muted: true)),
|
||||
);
|
||||
}
|
||||
return MultitabPane<String>(
|
||||
@@ -357,12 +349,7 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
color: foreground,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
);
|
||||
final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
|
||||
final editable = EditableText(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
@@ -398,7 +385,10 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
/// Advance width of one monospace glyph in [style].
|
||||
static double _charWidth(TextStyle style) {
|
||||
final tp = TextPainter(text: TextSpan(text: '0', style: style), textDirection: TextDirection.ltr)..layout();
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: '0', style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return tp.width;
|
||||
}
|
||||
}
|
||||
@@ -417,7 +407,8 @@ class _RulerPainter extends CustomPainter {
|
||||
Offset(x, size.height),
|
||||
Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1);
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -36,7 +36,10 @@ class SyntaxTextController extends TextEditingController {
|
||||
if (source == _highlightedText) return;
|
||||
|
||||
_highlighting = true;
|
||||
_syntax.highlight(path, source).then((result) {
|
||||
_syntax
|
||||
.highlight(path, source)
|
||||
.then(
|
||||
(result) {
|
||||
_highlighting = false;
|
||||
if (text != source) {
|
||||
_requestHighlight();
|
||||
@@ -45,9 +48,11 @@ class SyntaxTextController extends TextEditingController {
|
||||
_highlightedText = source;
|
||||
_spans = result.spans;
|
||||
notifyListeners();
|
||||
}, onError: (_) {
|
||||
},
|
||||
onError: (_) {
|
||||
_highlighting = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -57,11 +62,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
|
||||
final tokens = _tokens;
|
||||
if (_spans.isEmpty || tokens == null || text.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
@@ -125,20 +126,17 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Gap before this span — plain text.
|
||||
if (spanCharStart > charPos) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos, spanCharStart),
|
||||
style: style,
|
||||
));
|
||||
children.add(TextSpan(text: source.substring(charPos, spanCharStart), style: style));
|
||||
}
|
||||
|
||||
// The highlighted span.
|
||||
if (spanCharEnd > spanCharStart) {
|
||||
children.add(TextSpan(
|
||||
children.add(
|
||||
TextSpan(
|
||||
text: source.substring(spanCharStart, spanCharEnd),
|
||||
style: style?.copyWith(
|
||||
color: TreeSitterService.colorForRole(span.role, tokens),
|
||||
style: style?.copyWith(color: TreeSitterService.colorForRole(span.role, tokens)),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
charPos = spanCharEnd;
|
||||
@@ -146,10 +144,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Trailing plain text.
|
||||
if (charPos < source.length) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos),
|
||||
style: style,
|
||||
));
|
||||
children.add(TextSpan(text: source.substring(charPos), style: style));
|
||||
}
|
||||
|
||||
return TextSpan(style: style, children: children);
|
||||
|
||||
@@ -79,13 +79,7 @@ class VimResult {
|
||||
/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual]
|
||||
/// selects between collapse-to-caret (normal) and extend-from-anchor
|
||||
/// (visual) for motions, and enables the `visual*` range ops.
|
||||
VimResult applyVim(
|
||||
String action,
|
||||
TextEditingValue v, {
|
||||
VimRegister register = VimRegister.empty,
|
||||
bool visual = false,
|
||||
int count = 1,
|
||||
}) {
|
||||
VimResult applyVim(String action, TextEditingValue v, {VimRegister register = VimRegister.empty, bool visual = false, int count = 1}) {
|
||||
final t = v.text;
|
||||
final caret = v.selection.extentOffset.clamp(0, t.length);
|
||||
final anchor = v.selection.baseOffset.clamp(0, t.length);
|
||||
@@ -226,7 +220,8 @@ int _up(String t, int off) {
|
||||
int _cls(String ch) {
|
||||
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0;
|
||||
final c = ch.codeUnitAt(0);
|
||||
final isWord = (c >= 0x30 && c <= 0x39) || // 0-9
|
||||
final isWord =
|
||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||
(c >= 0x41 && c <= 0x5A) || // A-Z
|
||||
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||
c == 0x5F; // _
|
||||
@@ -279,13 +274,19 @@ int _wordEnd(String t, int off) {
|
||||
// -- Edit helpers -----------------------------------------------------------
|
||||
|
||||
VimResult _collapsed(String text, int caret) => VimResult(
|
||||
TextEditingValue(text: text, selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length))),
|
||||
);
|
||||
TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length)),
|
||||
),
|
||||
);
|
||||
|
||||
VimResult _insertAt(String t, int caret) => VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length))),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length)),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
);
|
||||
|
||||
VimResult _deleteChar(String t, int caret, int count) {
|
||||
final le = _lineEnd(t, caret);
|
||||
@@ -298,7 +299,10 @@ VimResult _deleteChar(String t, int caret, int count) {
|
||||
final nle = _lineEnd(nt, caret);
|
||||
final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls);
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ncaret)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ncaret),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
);
|
||||
}
|
||||
@@ -324,7 +328,10 @@ VimResult _deleteLines(String t, int caret, int count) {
|
||||
caretLineStart = ls;
|
||||
}
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart))),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart)),
|
||||
),
|
||||
register: reg,
|
||||
);
|
||||
}
|
||||
@@ -337,7 +344,10 @@ VimResult _deleteToEnd(String t, int caret) {
|
||||
final ls = _lineStart(nt, caret);
|
||||
final nle = _lineEnd(nt, caret);
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls))),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls)),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
);
|
||||
}
|
||||
@@ -349,7 +359,10 @@ VimResult _deleteToOffset(String t, int caret, int target, {bool insert = false}
|
||||
final removed = t.substring(lo, hi);
|
||||
final nt = t.replaceRange(lo, hi, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
enterInsert: insert,
|
||||
);
|
||||
@@ -366,7 +379,10 @@ VimResult _changeLine(String t, int caret, int count) {
|
||||
final removed = t.substring(ls, end);
|
||||
final nt = t.replaceRange(ls, end, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ls),
|
||||
),
|
||||
register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true),
|
||||
enterInsert: true,
|
||||
);
|
||||
@@ -382,7 +398,10 @@ VimResult _yankLines(String t, int caret, int count) {
|
||||
}
|
||||
final yanked = t.substring(ls, end);
|
||||
return VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: caret)),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: caret),
|
||||
),
|
||||
register: VimRegister(yanked.endsWith('\n') ? yanked : '$yanked\n', linewise: true),
|
||||
);
|
||||
}
|
||||
@@ -394,7 +413,12 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
|
||||
if (before) {
|
||||
final ls = _lineStart(t, caret);
|
||||
final nt = t.replaceRange(ls, ls, body);
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls))));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final le = _lineEnd(t, caret);
|
||||
final insertAt = le < t.length ? le + 1 : t.length;
|
||||
@@ -402,12 +426,22 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
|
||||
final chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}';
|
||||
final nt = t.replaceRange(insertAt, insertAt, chunk);
|
||||
final caretLine = le < t.length ? insertAt : insertAt + 1;
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine))));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine)),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Charwise: p pastes after the caret, P at the caret.
|
||||
final at = before ? caret : _clamp(caret + 1, 0, t.length);
|
||||
final nt = t.replaceRange(at, at, reg.text);
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: at + reg.text.length - 1)));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: at + reg.text.length - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
VimResult _openLine(String t, int caret, {required bool below}) {
|
||||
@@ -415,14 +449,20 @@ VimResult _openLine(String t, int caret, {required bool below}) {
|
||||
final le = _lineEnd(t, caret);
|
||||
final nt = t.replaceRange(le, le, '\n');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: le + 1)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: le + 1),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
}
|
||||
final ls = _lineStart(t, caret);
|
||||
final nt = t.replaceRange(ls, ls, '\n');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ls),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
}
|
||||
@@ -435,7 +475,10 @@ VimResult _deleteRange(String t, int anchor, int caret, {required bool insert})
|
||||
final removed = t.substring(lo, hi);
|
||||
final nt = t.replaceRange(lo, hi, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
enterInsert: insert,
|
||||
);
|
||||
@@ -445,7 +488,10 @@ VimResult _yankRange(String t, int anchor, int caret) {
|
||||
final lo = anchor < caret ? anchor : caret;
|
||||
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
|
||||
return VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(t.substring(lo, hi)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,17 +50,11 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
if (c.error != null && c.rootPath == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(c.error!, muted: true),
|
||||
);
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(c.error!, muted: true));
|
||||
}
|
||||
final root = c.rootPath;
|
||||
if (root == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return Column(
|
||||
@@ -98,18 +92,12 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
final matches = c.allLoadedEntries().where((e) {
|
||||
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
|
||||
}).toList();
|
||||
return [
|
||||
for (final e in matches) _FilteredFileRow(entry: e),
|
||||
];
|
||||
return [for (final e in matches) _FilteredFileRow(entry: e)];
|
||||
}
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
@@ -129,33 +117,19 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
controller: controller,
|
||||
depth: depth,
|
||||
),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
depth: depth,
|
||||
),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
@@ -173,11 +147,7 @@ class _DirRow extends StatelessWidget {
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(
|
||||
const ChevronRightIcon(),
|
||||
size: 10,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
),
|
||||
@@ -186,11 +156,7 @@ class _DirRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.depth,
|
||||
});
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
@@ -202,11 +168,7 @@ class _FileRow extends StatelessWidget {
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => _openFile(context, path),
|
||||
label: name,
|
||||
),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,13 +180,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({
|
||||
required this.depth,
|
||||
required this.onTap,
|
||||
required this.label,
|
||||
this.leading,
|
||||
this.rotateLeading = false,
|
||||
});
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -252,12 +208,7 @@ class _Row extends StatelessWidget {
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(label, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,10 +26,6 @@ class GitExtension extends ClideExtension {
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'git.branch',
|
||||
priority: 10,
|
||||
build: (_) => const GitStatusItem(),
|
||||
),
|
||||
StatusItemContribution(id: 'git.branch', priority: 10, build: (_) => const GitStatusItem()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -114,9 +114,7 @@ class GitController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<bool> stash({String? message}) async {
|
||||
final r = await ipc.request('git.stash', args: {
|
||||
if (message != null) 'message': message,
|
||||
});
|
||||
final r = await ipc.request('git.stash', args: {'message': ?message});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,57 +87,26 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
||||
),
|
||||
if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.', muted: true),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: _applyFilter(c.conflicted),
|
||||
actions: const [],
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
|
||||
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
|
||||
],
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
@@ -149,9 +118,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
final paths = [for (final e in c.untracked) e['path'] as String];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
@@ -160,7 +127,8 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
_SmallAction(
|
||||
label: 'Pull',
|
||||
semanticsLabel: 'git pull',
|
||||
onTap: () => unawaited(controller.pull()),
|
||||
child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
|
||||
const SizedBox(width: 4),
|
||||
_SmallAction(
|
||||
label: 'Push',
|
||||
semanticsLabel: 'git push',
|
||||
onTap: () => unawaited(controller.push()),
|
||||
),
|
||||
_SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -209,11 +165,7 @@ class _BranchHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _CommitInput extends StatelessWidget {
|
||||
const _CommitInput({
|
||||
required this.commitMsg,
|
||||
required this.commitFocus,
|
||||
required this.controller,
|
||||
});
|
||||
const _CommitInput({required this.commitMsg, required this.commitFocus, required this.controller});
|
||||
|
||||
final TextEditingController commitMsg;
|
||||
final FocusNode commitFocus;
|
||||
@@ -232,19 +184,12 @@ class _CommitInput extends StatelessWidget {
|
||||
label: 'commit message',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: commitMsg,
|
||||
focusNode: commitFocus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
style: TextStyle(fontFamily: clideUiFamily, fontWeight: clideUiDefaultWeight, fontSize: clideFontCaption, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 3,
|
||||
@@ -268,9 +213,11 @@ class _CommitInput extends StatelessWidget {
|
||||
void _doCommit() {
|
||||
final msg = commitMsg.text.trim();
|
||||
if (msg.isEmpty) return;
|
||||
unawaited(controller.commit(msg).then((hash) {
|
||||
unawaited(
|
||||
controller.commit(msg).then((hash) {
|
||||
if (hash != null) commitMsg.clear();
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +228,7 @@ class _GroupAction {
|
||||
}
|
||||
|
||||
class _FileGroup extends StatelessWidget {
|
||||
const _FileGroup({
|
||||
required this.label,
|
||||
required this.entries,
|
||||
this.actions = const [],
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
const _FileGroup({required this.label, required this.entries, this.actions = const [], this.onStage, this.onUnstage, this.onDiscard});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> entries;
|
||||
@@ -309,39 +249,20 @@ class _FileGroup extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'$label (${entries.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
color: tokens.sidebarForeground,
|
||||
child: ClideText('$label (${entries.length})', fontSize: clideFontCaption, muted: true, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
for (final a in actions) ...[
|
||||
_SmallAction(label: a.label, onTap: a.onTap),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
for (final a in actions) ...[_SmallAction(label: a.label, onTap: a.onTap), const SizedBox(width: 4)],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
_GitFileRow(
|
||||
entry: entry,
|
||||
onStage: onStage,
|
||||
onUnstage: onUnstage,
|
||||
onDiscard: onDiscard,
|
||||
),
|
||||
for (final entry in entries) _GitFileRow(entry: entry, onStage: onStage, onUnstage: onUnstage, onDiscard: onDiscard),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GitFileRow extends StatelessWidget {
|
||||
const _GitFileRow({
|
||||
required this.entry,
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
const _GitFileRow({required this.entry, this.onStage, this.onUnstage, this.onDiscard});
|
||||
|
||||
final Map<String, Object?> entry;
|
||||
final void Function(String path)? onStage;
|
||||
@@ -371,39 +292,15 @@ class _GitFileRow extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: clideFontCaption,
|
||||
color: _stateColor(state, tokens),
|
||||
),
|
||||
ClideText(_stateIndicator(state), fontSize: clideFontCaption, color: _stateColor(state, tokens)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
|
||||
),
|
||||
if (hovered) ...[
|
||||
if (onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => onStage!(path),
|
||||
),
|
||||
if (onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => onUnstage!(path),
|
||||
),
|
||||
if (onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => onDiscard!(path),
|
||||
),
|
||||
if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
|
||||
if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
|
||||
if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -447,11 +344,7 @@ class _GitFileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SmallAction extends StatelessWidget {
|
||||
const _SmallAction({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.semanticsLabel,
|
||||
});
|
||||
const _SmallAction({required this.label, required this.onTap, this.semanticsLabel});
|
||||
|
||||
final String label;
|
||||
final String? semanticsLabel;
|
||||
@@ -467,11 +360,7 @@ class _SmallAction extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -479,11 +368,7 @@ class _SmallAction extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DiscardConfirmDialog extends StatelessWidget {
|
||||
const _DiscardConfirmDialog({
|
||||
required this.path,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
const _DiscardConfirmDialog({required this.path, required this.onConfirm, required this.onCancel});
|
||||
|
||||
final String path;
|
||||
final VoidCallback onConfirm;
|
||||
@@ -505,30 +390,16 @@ class _DiscardConfirmDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
'Discard changes?',
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
ClideText('Discard changes?', color: tokens.globalForeground),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(
|
||||
'Unstaged changes to $name will be permanently lost.',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: 'Cancel',
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: onCancel,
|
||||
),
|
||||
ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: 'Discard',
|
||||
onPressed: onConfirm,
|
||||
),
|
||||
ClideButton(label: 'Discard', onPressed: onConfirm),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -51,13 +51,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
|
||||
void _openBranchPicker() {
|
||||
final kernel = ClideKernel.of(context);
|
||||
kernel.dialog.show<String>(
|
||||
(ctx, dismiss) => _BranchPicker(
|
||||
ipc: kernel.ipc,
|
||||
currentBranch: _branch,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
kernel.dialog.show<String>((ctx, dismiss) => _BranchPicker(ipc: kernel.ipc, currentBranch: _branch, onDismiss: dismiss));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -79,17 +73,9 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(
|
||||
const GitBranchIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
ClideIcon(const GitBranchIcon(), size: 12, color: tokens.statusBarForeground),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.statusBarForeground),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -100,11 +86,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
}
|
||||
|
||||
class _BranchPicker extends StatefulWidget {
|
||||
const _BranchPicker({
|
||||
required this.ipc,
|
||||
required this.currentBranch,
|
||||
required this.onDismiss,
|
||||
});
|
||||
const _BranchPicker({required this.ipc, required this.currentBranch, required this.onDismiss});
|
||||
|
||||
final DaemonClient ipc;
|
||||
final String? currentBranch;
|
||||
@@ -139,9 +121,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (r.ok) {
|
||||
_branches = [
|
||||
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
|
||||
];
|
||||
_branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>()];
|
||||
} else {
|
||||
_error = r.error?.message ?? 'failed to load branches';
|
||||
}
|
||||
@@ -210,11 +190,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
final b = _branches[i];
|
||||
final name = b['name'] as String? ?? '';
|
||||
final current = b['current'] as bool? ?? false;
|
||||
return _BranchRow(
|
||||
name: name,
|
||||
current: current,
|
||||
onTap: current ? null : () => unawaited(_checkout(name)),
|
||||
);
|
||||
return _BranchRow(name: name, current: current, onTap: current ? null : () => unawaited(_checkout(name)));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -226,11 +202,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
}
|
||||
|
||||
class _BranchRow extends StatelessWidget {
|
||||
const _BranchRow({
|
||||
required this.name,
|
||||
required this.current,
|
||||
this.onTap,
|
||||
});
|
||||
const _BranchRow({required this.name, required this.current, this.onTap});
|
||||
|
||||
final String name;
|
||||
final bool current;
|
||||
@@ -250,11 +222,7 @@ class _BranchRow extends StatelessWidget {
|
||||
if (current)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(
|
||||
const CheckIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusSuccess,
|
||||
),
|
||||
child: ClideIcon(const CheckIcon(), size: 12, color: tokens.statusSuccess),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,5 @@ class IpcStatusExtension extends ClideExtension {
|
||||
String get version => '0.2.0';
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
StatusItemContribution(
|
||||
id: 'ipc-status.indicator',
|
||||
priority: 100,
|
||||
build: (_) => const ToolStatusItem(),
|
||||
),
|
||||
];
|
||||
List<ContributionPoint> get contributions => [StatusItemContribution(id: 'ipc-status.indicator', priority: 100, build: (_) => const ToolStatusItem())];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user