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.
|
||||
|
||||
+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,14 @@ 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);
|
||||
|
||||
@@ -3125,3 +3125,403 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'backlog', 'ready', NULL, '2026-06-10 11:33:26', '2026-06-10 11:33:26', '2026-06-10 11:33:26', NULL, 'f280d9b2793130900d5e470c4cb6c088', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'status', 'backlog', 'ready', NULL, '2026-06-10 11:33:35', '2026-06-10 11:33:35', '2026-06-10 11:33:35', NULL, 'bb110c716ec26f37ed4ece36b582d4d5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2J2HWD66QAFDDRRWS5NM48', 'status', 'ready', 'done', NULL, '2026-06-10 11:36:35', '2026-06-10 11:36:35', '2026-06-10 11:36:35', NULL, '376bbab713452158f399f02429870546', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'description', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
|
||||
|
||||
**Where**
|
||||
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
|
||||
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
|
||||
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
|
||||
|
||||
**Direction (settle in review)**
|
||||
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
|
||||
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
|
||||
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
|
||||
|
||||
**Acceptance**
|
||||
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.', 'The logo-mark spinner shown on in-progress activity/holder cards in the Claude conversation is too small to read as a spinner — it reads as a static speck. Enlarge it so the running state is legible at a glance.
|
||||
|
||||
**Where**
|
||||
- `ClideSpinner` (lib/widgets/src/clide_spinner.dart) — defaults to size 14; renders the logo SVG at width/height = size.
|
||||
- `ClideStatusIndicator` (lib/widgets/src/clide_status_indicator.dart) — default size 14; maps running→ClideSpinner, success→check, error→cross at the same size.
|
||||
- Call sites: holder_card.dart:117 and :199 pass `size: 12` — the small value the user is seeing.
|
||||
|
||||
**Direction (settle in review)**
|
||||
- Bump the spinner size on the activity cards (the `size: 12` call sites, and/or the indicator default) to something clearly legible — pull a concrete value from the ui-design control-geometry tokens rather than a magic number.
|
||||
- Keep the running spinner, success check, and error cross visually balanced at the new size (they share `size`), so the card doesn''t jump when the state settles.
|
||||
- Check the other ClideSpinner/StatusIndicator consumers (status surfaces) so the bump doesn''t bloat unrelated spots — may warrant sizing the cards explicitly rather than changing the shared default.
|
||||
|
||||
**Acceptance**
|
||||
- The in-progress spinner on conversation activity cards is comfortably distinguishable as a spinning indicator; success/error glyphs stay aligned at the same footprint.
|
||||
|
||||
**Initial trial**
|
||||
- For the first cut, double the current size: the `size: 12` activity-card call sites go to `size: 24`. Trial that footprint, then settle the final value in review against the control-geometry tokens.', NULL, '2026-06-10 11:55:15', '2026-06-10 11:55:15', '2026-06-10 11:55:15', NULL, '3be7c3ff633afbc67c91dd3f97c27e6f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4KS233FGZE9H7ABWR', 'status', 'ready', 'done', NULL, '2026-06-10 12:05:17', '2026-06-10 12:05:17', '2026-06-10 12:05:17', NULL, 'a19ce81190caca50c2628a98835d41a3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'parent_id', NULL, 'T-276', NULL, '2026-06-10 12:05:21', '2026-06-10 12:05:21', '2026-06-10 12:05:21', NULL, 'fefee4b227754f03c985841fb869a346', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'description', NULL, 'AnimatedSwitcher in ClideStatusIndicator throws "Duplicate keys found" (Stack has multiple children with key [<''running''>]) during normal app run, cascading into a flood of follow-on errors ("Tried to build dirty widget in the wrong build scope", "debugNeedsLayout is not true", "ScrollController attached to multiple scroll views", etc).
|
||||
|
||||
Location: lib/widgets/src/clide_status_indicator.dart:37 (AnimatedSwitcher at build()).
|
||||
|
||||
Root cause: each status maps to a child with a fixed ValueKey (''running'' / ''success'' / ''error''). AnimatedSwitcher cross-fades the outgoing and incoming child inside a Stack for its 200ms duration. When the status flips back to a value whose previous child is still animating out (e.g. running -> success -> running within 200ms, or repeated running rebuilds), the still-exiting child and the new child both carry ValueKey(''running'') and collide in the Stack -> duplicate-key assertion. The downstream exceptions are the framework unwinding from the failed build.
|
||||
|
||||
Repro: observed live during `make run` with two Claude panes bound (primary + secondary-1); status indicators flipping quickly trigger it.
|
||||
|
||||
Fix direction: the ValueKey must be unique per indicator instance, not just per status, so two instances (or an in-flight transition) never share a key. Options: key by status combined with a stable per-widget id, or drop the const keys and let AnimatedSwitcher key on child type. Add a widget test that rapidly toggles status within the switch duration and pumps mid-transition to guard the regression.', NULL, '2026-06-10 12:05:39', '2026-06-10 12:05:39', '2026-06-10 12:05:39', NULL, '540deffcc7de142a4732a92b5f1dc5be', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'priority', 'medium', 'high', NULL, '2026-06-10 12:05:39', '2026-06-10 12:05:39', '2026-06-10 12:05:39', NULL, 'e845355200ef8aaabc986d577724d39e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:07:39', '2026-06-10 12:07:39', '2026-06-10 12:07:39', NULL, 'b013a394e376245d8248d776021e0314', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:08:14', '2026-06-10 12:08:14', '2026-06-10 12:08:14', NULL, 'f56fda6382cbec3561ef644954c04f43', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'backlog', NULL, '2026-06-10 12:08:18', '2026-06-10 12:08:18', '2026-06-10 12:08:18', NULL, '12eee2948e8f79732b40384a40429e14', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:12:30', '2026-06-10 12:12:30', '2026-06-10 12:12:30', NULL, '6b9aaf9bd7ec3ad4ad3b95d1468ff9cf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'status', 'ready', 'done', NULL, '2026-06-10 12:14:00', '2026-06-10 12:14:00', '2026-06-10 12:14:00', NULL, 'd90deec4e27b4298c6f7280338599c01', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'ready', 'done', NULL, '2026-06-10 12:31:23', '2026-06-10 12:31:23', '2026-06-10 12:31:23', NULL, 'dbac53eef0b8d58dbb5f00a2f77ee52f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'done', 'done', NULL, '2026-06-10 12:32:07', '2026-06-10 12:32:07', '2026-06-10 12:32:07', NULL, 'ea979d683072b321ce6eae3dbbbf6c31', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'ready', 'done', NULL, '2026-06-10 12:51:50', '2026-06-10 12:51:50', '2026-06-10 12:51:50', NULL, 'a8a700b9b92afcb998b5c2e69660e1b2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:56:38', '2026-06-10 12:56:38', '2026-06-10 12:56:38', NULL, '0c7b9f022143b733461ca7b1b715c435', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:00:19', '2026-06-10 13:00:19', '2026-06-10 13:00:19', NULL, 'a394b4a8b72a7bc99c7fc1a1ac6a2f19', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2TY91VHK7TPKPMZ11EG3TM', 'status', 'ready', 'done', NULL, '2026-06-10 13:00:29', '2026-06-10 13:00:29', '2026-06-10 13:00:29', NULL, '25246286c46b36a114817316658049c7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1S7613SYF0M9XQT5JNWM40', 'status', 'ready', 'done', NULL, '2026-06-10 13:15:15', '2026-06-10 13:15:15', '2026-06-10 13:15:15', NULL, '9cea01c55347a9c347bd195d322dd004', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'status', 'ready', 'done', NULL, '2026-06-10 13:18:57', '2026-06-10 13:18:57', '2026-06-10 13:18:57', NULL, '02a8ea1b2623be255101f56c077f63ee', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:24:15', '2026-06-10 13:24:15', '2026-06-10 13:24:15', NULL, '85d0441183cc6f64fb1a956522a60f4a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'ready', 'in_progress', NULL, '2026-06-10 13:41:17', '2026-06-10 13:41:17', '2026-06-10 13:41:17', NULL, '14fa1745f31d356412fe910180183a53', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:28', '2026-06-10 13:53:28', '2026-06-10 13:53:28', NULL, 'addfc7d2a002212e979b3d7237cf37ce', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:55', '2026-06-10 13:53:55', '2026-06-10 13:53:55', NULL, '72773d8d91af176305bd6c1b7d5d65be', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:54:02', '2026-06-10 13:54:02', '2026-06-10 13:54:02', NULL, '5cb488441033ac5a781d4b7d91dbda6f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'ready', 'done', NULL, '2026-06-10 14:14:33', '2026-06-10 14:14:33', '2026-06-10 14:14:33', NULL, 'd028dbbdbad663a682c5ed4d92dc7cc2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'ready', 'done', NULL, '2026-06-10 14:29:07', '2026-06-10 14:29:07', '2026-06-10 14:29:07', NULL, 'b7a07e0516cc232a959ff1930f338f15', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'description', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.', 'On first launch, check if tmux is on PATH. If missing, prompt the user with platform-appropriate install instructions (apt, dnf, brew) or offer to install automatically. Claude pane requires tmux per D-41; without it the primary session cannot persist.
|
||||
|
||||
Obsolete: superseded by D-77/D-78. The Claude pane no longer requires tmux — it is driven over the stream-json stdio control protocol and persists via --resume (transcript files), not tmux. D-77 explicitly amends D-41 (tmux-for-persistence → --resume; tmux retained only for the general terminal). The ''primary session cannot persist without tmux'' premise is gone, so a first-launch tmux detect/install gate is unwarranted. Cancelling.', NULL, '2026-06-10 14:41:37', '2026-06-10 14:41:37', '2026-06-10 14:41:37', NULL, '020a1027abc50a0ab63ac839380dc767', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4QKBQY7FPCNX6N28R', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 14:41:47', '2026-06-10 14:41:47', '2026-06-10 14:41:47', NULL, 'c1323729d101d47e5ea20df81e40735a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4TQEYESK545T8F164', 'description', 'Reorder tabs in the sidebar and context panel icon rails by dragging. Persist order to project settings.', 'Reorder tabs in the sidebar and context panel icon rails by dragging. Persist order to project settings.
|
||||
|
||||
Notes (2026-06-10):
|
||||
1. Applies to BOTH rails — the left sidebar icon rail and the right context-bar icon rail. Reordering + persistence must work the same on each.
|
||||
2. After ordering, the left-most (first) item in the rail is the one that opens by default.', NULL, '2026-06-10 14:45:17', '2026-06-10 14:45:17', '2026-06-10 14:45:17', NULL, '86f1c477f9408ce87b624799a73b82c0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'backlog', 'ready', NULL, '2026-06-10 14:46:51', '2026-06-10 14:46:51', '2026-06-10 14:46:51', NULL, 'a3785538cc5ea4040c7f88ee615342b4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'ready', 'in_progress', NULL, '2026-06-10 14:53:03', '2026-06-10 14:53:03', '2026-06-10 14:53:03', NULL, '6fe6ac08d39ee01463fc6e629a0b00d7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'description', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
|
||||
|
||||
**Errno constants (audit item #23):**
|
||||
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
|
||||
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
|
||||
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
|
||||
|
||||
**Logger standardization (audit item #22, partial #26):**
|
||||
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
|
||||
- The Flutter-host process often consumes stderr, so log lines disappear silently.
|
||||
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
|
||||
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
|
||||
|
||||
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.', 'From the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md). Two cleanup items rolled together:
|
||||
|
||||
**Errno constants (audit item #23):**
|
||||
- Magic numbers (`4` for EINTR, `9` for EBADF, `28` for SIGWINCH, `1` for SIGHUP, `32` for EPIPE) appear inline across `lib/src/pty/session.dart` and `lib/src/pty/native_pty.dart`.
|
||||
- Centralize them in `lib/src/pty/errors.dart` or a sibling `posix.dart` as named constants.
|
||||
- Existing `lib/src/ipc/errno_mapping.dart` already has a `PosixErrno` class — extend it or move to a shared location both layers import from.
|
||||
|
||||
**Logger standardization (audit item #22, partial #26):**
|
||||
- `lib/src/ipc/server.dart` uses `stderr.writeln(...)` directly; the rest of the daemon either uses no logger or a custom one.
|
||||
- The Flutter-host process often consumes stderr, so log lines disappear silently.
|
||||
- Pick one logger interface (kernel `log` already exists for the app side), wire `DaemonServer` and the daemon-side handlers to use it.
|
||||
- Dispatch error messages should prefix with the request `cmd` so log correlation works (audit item #26).
|
||||
|
||||
**Out of scope for this ticket:** changes to log-LEVEL policy, log retention, log files vs stderr — pure substitution job.
|
||||
|
||||
Disposition (2026-06-10): mostly already done before pickup.
|
||||
- #23 (errno constants): DONE prior. Magic numbers are centralized — errno values in lib/src/ipc/errno_mapping.dart (PosixErrno: eintr=4, ebadf=9, epipe=32, …), signals in lib/src/pty/ffi/libc.dart (sighup=1, sigwinch=28). native_pty.dart uses PosixErrno.* and libc.* throughout; no inline magic numbers remain. The ticket''s lib/src/pty/session.dart never existed at that path.
|
||||
- #22 (logger): DONE prior. lib/src/ipc/server.dart imports the kernel Logger, holds a ''final Logger log'', and logs via log.error/warn/info(''ipc'', …). No stderr.writeln/print anywhere in lib/src/ipc, lib/src/pty, or lib/src/daemon. Folded in by the D-56 daemon dissolution + PTY FFI pivot.
|
||||
- #26 (cmd correlation): the only live remnant — the catch-all ''dispatch threw'' log omitted the request cmd. Fixed: it now logs ''dispatch threw for "<cmd>"''. Internal logging only; no changelog.', NULL, '2026-06-10 14:58:32', '2026-06-10 14:58:32', '2026-06-10 14:58:32', NULL, '067666253769388e306dd99c4f976df9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM734YZ060Q63H40EYG', 'status', 'in_progress', 'done', NULL, '2026-06-10 14:58:43', '2026-06-10 14:58:43', '2026-06-10 14:58:43', NULL, '192064fbd5628b282512128ddc3d2688', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'backlog', 'ready', NULL, '2026-06-10 15:01:43', '2026-06-10 15:01:43', '2026-06-10 15:01:43', NULL, 'a002c3ce8b3efa35c18d9708ed37c9c3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'backlog', 'ready', NULL, '2026-06-10 15:01:52', '2026-06-10 15:01:52', '2026-06-10 15:01:52', NULL, 'dee252ba6fbb07189c947101968e1743', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'ready', 'in_progress', NULL, '2026-06-10 15:01:54', '2026-06-10 15:01:54', '2026-06-10 15:01:54', NULL, '1c3ed11e37579ea9a3a21e85e3ba110a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'ready', 'in_progress', NULL, '2026-06-10 15:02:14', '2026-06-10 15:02:14', '2026-06-10 15:02:14', NULL, 'c5844c06118422cb09538405b12fe478', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'description', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
|
||||
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
|
||||
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
|
||||
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
|
||||
|
||||
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
|
||||
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
|
||||
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
|
||||
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
|
||||
|
||||
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).
|
||||
|
||||
Correction (2026-06-10): this ticket''s ''see Q-9'' reference is stale — Q-9 is ''Lua runtime vendoring'', unrelated. The search-everywhere / double-tap-modifier gap is now tracked by T-341.', NULL, '2026-06-10 15:07:52', '2026-06-10 15:07:52', '2026-06-10 15:07:52', NULL, 'f5caba1a25d32651bbd7503c906633cf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'description', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
|
||||
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
|
||||
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
|
||||
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
|
||||
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).
|
||||
|
||||
Correction (2026-06-10): ''see Q-9'' is stale (Q-9 is Lua runtime vendoring). The double-Shift ''Search Everywhere'' chord is NOT expressible by the current matcher (bare/double modifiers unsupported) — tracked in T-341. This preset maps quick-open to Ctrl+Shift+N and the palette to Ctrl+Shift+A as the expressible IntelliJ equivalents.', NULL, '2026-06-10 15:07:52', '2026-06-10 15:07:52', '2026-06-10 15:07:52', NULL, '18bc4f20a98f7528f3a9a976e34d9377', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'description', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.', 'Conditional import behind TreeSitterService: native impl uses dart:ffi to libtree-sitter.so, web impl uses dart:js_interop to web-tree-sitter (official emscripten build from tree-sitter org). Same grammar .wasm files on both platforms. Vendor web-tree-sitter .wasm + JS glue as Flutter web assets, pinned version, added to licenses.yaml.
|
||||
|
||||
Cancelled 2026-06-10 (backlog relevance sweep): contradicts the desktop-first guardrail (CLAUDE.md) - web is an explicit non-goal / happy-accident only. TreeSitterService is FFI-only and there is no shipped web product, so a web-tree-sitter dual-path is not wanted. Reopen only if web ever becomes a real target.', NULL, '2026-06-10 15:13:50', '2026-06-10 15:13:50', '2026-06-10 15:13:50', NULL, 'ad42a5b7d9d3cc5c5e0e1ed97b9858aa', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'description', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.', 'Prompt before running extensions or loading project settings in untrusted repositories. Trust decision persisted per repo path. Untrusted mode disables third-party extensions and restricts IPC commands.
|
||||
|
||||
Cancelled 2026-06-10 (relevance sweep): premature. Third-party (Lua) extension loading is not shipped - ExtensionScanner.discover is test-only and the Lua runtime is a Tier-6 skeleton. Nothing to trust-gate yet; revisit at Tier 6 when external extension loading lands (the trust surface will likely be Lua sandboxing per D-19, not a per-repo prompt).', NULL, '2026-06-10 15:13:52', '2026-06-10 15:13:52', '2026-06-10 15:13:52', NULL, 'c4e57efc161c7afbc5e8a3c3d08b8d74', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'description', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.', 'Spec lists PRs as a left-panel section (icon rail position 5). No extension exists yet.
|
||||
|
||||
Cancelled 2026-06-10 (relevance sweep): spec''d in D-47 but unscoped, no extension exists, and the data path (git host API vs local metadata) is undecided. Closing to clear the backlog; file a fresh scoped story if a PRs surface is wanted.', NULL, '2026-06-10 15:13:52', '2026-06-10 15:13:52', '2026-06-10 15:13:52', NULL, '99b1fceb9134d0e6bead70da08a51710', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM4W194B2421P2SF83R', 'description', 'BUILD.md at app/native/linux-x64/ has TODO checklist: build from pinned source SHA in CI, record SHA-256, cross-compile for macOS (aarch64, x86_64) and Windows (x86_64). Currently built on contributor machine.', 'BUILD.md at app/native/linux-x64/ has TODO checklist: build from pinned source SHA in CI, record SHA-256, cross-compile for macOS (aarch64, x86_64) and Windows (x86_64). Currently built on contributor machine.
|
||||
|
||||
Path fix (2026-06-10 sweep): ticket says app/native/linux-x64/ - the app/ prefix is stale (D-56 dissolved the two-package layout). Correct path is native/linux-x64/BUILD.md. Work remains valid: native/linux-x64/libtree-sitter.so is committed but there is still no CI build/cross-compile job.', NULL, '2026-06-10 15:13:54', '2026-06-10 15:13:54', '2026-06-10 15:13:54', NULL, '3ddd8faeeaabb70a137165ec736e8a07', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM48MDE8ZZ82VWNY994', 'description', 'D-46 defines the boundary: content extensions (editor, claude, claude-control, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) move from app/lib/builtin/ to app/lib/extensions/. Incremental — one at a time, each behind a working build. Extension contract must support bundled Dart extension as a first-class category.', 'D-46 defines the boundary: content extensions (editor, claude, claude-control, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) move from app/lib/builtin/ to app/lib/extensions/. Incremental — one at a time, each behind a working build. Extension contract must support bundled Dart extension as a first-class category.
|
||||
|
||||
Path fix (2026-06-10 sweep): app/lib/builtin/ -> lib/builtin/ (app/ prefix stale per D-56). D-46 still confirmed/active. lib/extensions/ does not exist yet and the shipped extensions (editor, claude, markdown, diff, git-ui, pql, canvas, graph, decisions, tickets, todos, problems) are still under lib/builtin/. Migration unstarted, still valid.', NULL, '2026-06-10 15:13:56', '2026-06-10 15:13:56', '2026-06-10 15:13:56', NULL, '81d27a7cf777fc0290574d807938ea36', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6T6580D8ABDVTMNZW', 'description', 'Tier 6 of the build plan: the things that make clide a real product instead of a working prototype.
|
||||
|
||||
**Extension API (third-party Lua):**
|
||||
- The Lua runtime supporter tool (D-19) lands as a peer of pql/ptyc.
|
||||
- Manifest schema, capability gating, sandboxed FS/IPC access.
|
||||
- Same TabContribution / CommandContribution / etc. surface as built-in Dart extensions (D-15).
|
||||
- Marketplace / distribution story is OUT OF SCOPE for Tier 6 — local-install only.
|
||||
|
||||
**Settings UI (`builtin.settings-ui`):**
|
||||
- Schema-driven settings panel reading from the kernel SettingsStore.
|
||||
- Render strategy: form fields keyed off the schema each subsystem registers.
|
||||
- Edits write back to `.clide/settings.yaml`.
|
||||
|
||||
**Theming UI (`builtin.theme-picker` extends):**
|
||||
- Live preview of the four bundled themes (D-44).
|
||||
- Custom theme: import YAML, validate against schema, register at runtime.
|
||||
- Per-component override surface (long horizon).
|
||||
|
||||
**Distributable builds:**
|
||||
- AppImage / Flatpak for Linux, .dmg for macOS — see T-46.
|
||||
- Self-update mechanism — see T-47.
|
||||
- License manifest auto-regen as part of the release build.
|
||||
|
||||
Big epic — children land incrementally. Most concrete child tickets already exist; this is the umbrella.', 'Tier 6 of the build plan: the things that make clide a real product instead of a working prototype.
|
||||
|
||||
**Extension API (third-party Lua):**
|
||||
- The Lua runtime supporter tool (D-19) lands as a peer of pql/ptyc.
|
||||
- Manifest schema, capability gating, sandboxed FS/IPC access.
|
||||
- Same TabContribution / CommandContribution / etc. surface as built-in Dart extensions (D-15).
|
||||
- Marketplace / distribution story is OUT OF SCOPE for Tier 6 — local-install only.
|
||||
|
||||
**Settings UI (`builtin.settings-ui`):**
|
||||
- Schema-driven settings panel reading from the kernel SettingsStore.
|
||||
- Render strategy: form fields keyed off the schema each subsystem registers.
|
||||
- Edits write back to `.clide/settings.yaml`.
|
||||
|
||||
**Theming UI (`builtin.theme-picker` extends):**
|
||||
- Live preview of the four bundled themes (D-44).
|
||||
- Custom theme: import YAML, validate against schema, register at runtime.
|
||||
- Per-component override surface (long horizon).
|
||||
|
||||
**Distributable builds:**
|
||||
- AppImage / Flatpak for Linux, .dmg for macOS — see T-46.
|
||||
- Self-update mechanism — see T-47.
|
||||
- License manifest auto-regen as part of the release build.
|
||||
|
||||
Big epic — children land incrementally. Most concrete child tickets already exist; this is the umbrella.
|
||||
|
||||
Status note (2026-06-10 sweep): mixed completion. theme-picker is substantially implemented; settings-ui is a stub; the Lua runtime is skeleton-only (lib/lua/); distributable builds (T-46/T-47) remain deferred Tier-6 work. Epic stays open as the umbrella.', NULL, '2026-06-10 15:13:57', '2026-06-10 15:13:57', '2026-06-10 15:13:57', NULL, '15d4ca47570b23a19b16c22e40a8d73e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM66FTCTWHH9AQTNFKR', 'description', 'Minimize to system tray on Linux (AppIndicator) or Dock on macOS. Reopening from tray restores the window without cold boot. tmux sessions stay alive in background regardless.', 'Minimize to system tray on Linux (AppIndicator) or Dock on macOS. Reopening from tray restores the window without cold boot. tmux sessions stay alive in background regardless.
|
||||
|
||||
Scope split (2026-06-10 sweep): the session-persistence half is effectively done - tmux keeps Claude/terminal sessions alive across restart (D-41). The OS-tray/AppIndicator + dock half is a stub only (lib/kernel/src/tray.dart - TrayRegistry has no platform-channel wiring) and is Tier-6+. Remaining work = the tray integration.', NULL, '2026-06-10 15:13:59', '2026-06-10 15:13:59', '2026-06-10 15:13:59', NULL, '8a2142b2ae85644bdbe22627ce3bde77', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM67JSC5RKS6M9182KG', 'description', 'Spec lists images as a right-panel section. No extension exists yet.', 'Spec lists images as a right-panel section. No extension exists yet.
|
||||
|
||||
Relevance note (2026-06-10 sweep): likely superseded. The image card + full-screen lightbox shipped (T-249/T-252) and the canvas epic (T-317, D-91) folds image display into the unified drawing-card renderer rather than a separate context-panel tab. Confirm whether a distinct images rail section is still wanted; otherwise close in favor of the canvas path.', NULL, '2026-06-10 15:14:01', '2026-06-10 15:14:01', '2026-06-10 15:14:01', NULL, 'faf96dd64f003093273f6a331c56bfa8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7P6Q0DG4RG4CBHFJG', 'description', 'Audit all interactive widgets for Semantics coverage (labels, roles, states). Verify flutter test can locate and interact with every panel, button, and input via find.bySemanticsLabel. Run the existing a11y test suite and document gaps. Target: every user-facing action is testable without widget keys.', 'Audit all interactive widgets for Semantics coverage (labels, roles, states). Verify flutter test can locate and interact with every panel, button, and input via find.bySemanticsLabel. Run the existing a11y test suite and document gaps. Target: every user-facing action is testable without widget keys.
|
||||
|
||||
Reframe (2026-06-10 sweep): the original ''audit Semantics coverage'' framing is stale - test/a11y/ (semantic_coverage, contrast, keyboard_traversal, i18n) is now a mature per-PR gate per D-20. Re-scope to forward work: ratchet the semantic-coverage floor and deepen per-extension Semantics assertions, rather than a one-time review.', NULL, '2026-06-10 15:14:02', '2026-06-10 15:14:02', '2026-06-10 15:14:02', NULL, 'a83b56d7fee3b9a20bc0d78f6125532a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM79CNBXJ2S3CFQR7VM', 'description', 'Catch-all for the medium-priority items from the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md) that didn`t earn dedicated tickets:
|
||||
|
||||
- **#17** — `files.read` `readAsStringSync` is unguarded; UTF-8 errors / permissions / mid-read deletion become 500-style dispatch errors. Wrap in try/catch and emit a clean `IpcResponse.err`.
|
||||
- **#19** — `PtySession.close` swallows the 500ms timeout silently (`onTimeout: () {}`). Log when the timeout fires so we know SIGKILL was needed.
|
||||
- **#20** — Reader isolate treats every negative `read()` return that isn`t EINTR as EOF. Distinguish EBADF/EIO (real EOF) from transient EAGAIN (recoverable) and log the latter.
|
||||
- **#21** — `scm_rights.dart` reads cmsg-data fd without verifying `dataOffset + 4 <= msgControllen`. Bounds check before deref so a malformed peer can`t feed garbage as an fd.
|
||||
- **#25** — `_gitError` in `lib/src/daemon/git_commands.dart` always reports `tool_error`; push rejections / merge conflicts should map to `IpcExitCode.conflict` when stderr matches known patterns.
|
||||
- **#27** — `pane.spawn` returns `ok` even when `registry.write(id, bytes)` returned `n == -1`. Distinguish the failure.
|
||||
- **#28** — `IpcResponse.fromJson` throws `TypeError` on a malformed peer response missing `error`. Graceful degrade.
|
||||
- **#29** — PATH resolution in `native_pty.dart` uses the first existing match without `X_OK` check; non-executable files shadow valid binaries further along PATH.
|
||||
|
||||
Land each as a small focused commit; ticket closes when all items above are merged.', 'Catch-all for the medium-priority items from the PTY/IPC error-handling audit (T-18, see docs/audits/pty-ipc-error-handling-2026-05-05.md) that didn`t earn dedicated tickets:
|
||||
|
||||
- **#17** — `files.read` `readAsStringSync` is unguarded; UTF-8 errors / permissions / mid-read deletion become 500-style dispatch errors. Wrap in try/catch and emit a clean `IpcResponse.err`.
|
||||
- **#19** — `PtySession.close` swallows the 500ms timeout silently (`onTimeout: () {}`). Log when the timeout fires so we know SIGKILL was needed.
|
||||
- **#20** — Reader isolate treats every negative `read()` return that isn`t EINTR as EOF. Distinguish EBADF/EIO (real EOF) from transient EAGAIN (recoverable) and log the latter.
|
||||
- **#21** — `scm_rights.dart` reads cmsg-data fd without verifying `dataOffset + 4 <= msgControllen`. Bounds check before deref so a malformed peer can`t feed garbage as an fd.
|
||||
- **#25** — `_gitError` in `lib/src/daemon/git_commands.dart` always reports `tool_error`; push rejections / merge conflicts should map to `IpcExitCode.conflict` when stderr matches known patterns.
|
||||
- **#27** — `pane.spawn` returns `ok` even when `registry.write(id, bytes)` returned `n == -1`. Distinguish the failure.
|
||||
- **#28** — `IpcResponse.fromJson` throws `TypeError` on a malformed peer response missing `error`. Graceful degrade.
|
||||
- **#29** — PATH resolution in `native_pty.dart` uses the first existing match without `X_OK` check; non-executable files shadow valid binaries further along PATH.
|
||||
|
||||
Land each as a small focused commit; ticket closes when all items above are merged.
|
||||
|
||||
Item status (2026-06-10 sweep): from the T-18 audit, #16 (git error kinds) landed via T-79 and #22 (logging) via T-80. #21 (scm_rights.dart bounds check) is OBSOLETE - fd-passing/recvmsg was removed, the file no longer exists; drop it. Spot-checked still-open: #17 files.read unguarded readAsStringSync (files_commands.dart), #28 IpcResponse.fromJson TypeError (envelope.dart), #29 PATH X_OK check (native_pty.dart). ~7 items remain.', NULL, '2026-06-10 15:14:04', '2026-06-10 15:14:04', '2026-06-10 15:14:04', NULL, '94ea0fc1d09ea5eb98acf8ff624ac984', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5YG22EV7BFTX5RTPR', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, 'b459f350a8c1a86865579cde7f7b02b1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM50X0DX8XTVZEA5GN8', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, '351f1eede59364f6bb95653147b98ee2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6RDM2EKZX132GPV7M', 'status', 'backlog', 'cancelled', NULL, '2026-06-10 15:14:17', '2026-06-10 15:14:17', '2026-06-10 15:14:17', NULL, '992ba3fca9faf99417aaa1a7afa9e6be', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM42M9RK4399B4F4WSG', 'status', 'in_progress', 'done', NULL, '2026-06-10 15:17:59', '2026-06-10 15:17:59', '2026-06-10 15:17:59', NULL, '61000cdb8bb01686c6001f57d16ae717', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM60QRRNEEWG84VWKXC', 'status', 'in_progress', 'done', NULL, '2026-06-10 15:17:59', '2026-06-10 15:17:59', '2026-06-10 15:17:59', NULL, '81990604a08fbedc28075b95538625d6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'description', NULL, 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
|
||||
|
||||
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
|
||||
|
||||
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
|
||||
|
||||
SCOPE / DESIGN:
|
||||
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
|
||||
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
|
||||
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
|
||||
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
|
||||
|
||||
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
|
||||
|
||||
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.', NULL, '2026-06-10 15:27:01', '2026-06-10 15:27:01', '2026-06-10 15:27:01', NULL, '59b702a540ce65892ea2c6fce5d1a045', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'description', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
|
||||
|
||||
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
|
||||
|
||||
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
|
||||
|
||||
SCOPE / DESIGN:
|
||||
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
|
||||
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
|
||||
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
|
||||
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
|
||||
|
||||
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
|
||||
|
||||
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
|
||||
|
||||
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
|
||||
|
||||
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
|
||||
|
||||
SCOPE / DESIGN:
|
||||
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
|
||||
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
|
||||
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
|
||||
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
|
||||
|
||||
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
|
||||
|
||||
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
|
||||
|
||||
SCOPE CLARIFICATION (2026-06-10, from user):
|
||||
|
||||
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
|
||||
|
||||
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
|
||||
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
|
||||
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
|
||||
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
|
||||
|
||||
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', NULL, '2026-06-10 15:28:52', '2026-06-10 15:28:52', '2026-06-10 15:28:52', NULL, '0352b24d88a4bf24ede628880fe9b502', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'backlog', 'in_progress', NULL, '2026-06-10 15:54:23', '2026-06-10 15:54:23', '2026-06-10 15:54:23', NULL, '8061a1d449acdb1e467239de0dce5d1d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'in_progress', 'in_progress', NULL, '2026-06-10 16:02:55', '2026-06-10 16:02:55', '2026-06-10 16:02:55', NULL, '9c00a4d5fefdc3cf8f227b040bc78acb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'description', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
|
||||
|
||||
## Behaviour
|
||||
- Single-click a chip → toggle that type in/out of the list.
|
||||
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
|
||||
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
|
||||
- Tooltip per chip: "Click to toggle · double-click to isolate".
|
||||
|
||||
## Type mapping (pql → chip)
|
||||
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
|
||||
- Bug → bug
|
||||
- Ticket → story + task (leaf work items)
|
||||
- Epic → epic
|
||||
- Initiative → initiative
|
||||
|
||||
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
|
||||
|
||||
## Filtering
|
||||
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
|
||||
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
|
||||
|
||||
## Implementation notes
|
||||
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
|
||||
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
|
||||
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
|
||||
|
||||
## Wireframe
|
||||
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)', 'Add a row of type-filter toggle chips at the top of the tickets panel, directly below the "Filter tickets…" box (no section header — the chips read on their own). One chip per ticket type the user thinks in: Bug, Ticket, Epic, Initiative. All four ON by default.
|
||||
|
||||
## Behaviour
|
||||
- Single-click a chip → toggle that type in/out of the list.
|
||||
- Double-click a chip → isolate (solo) that type: turns it ON and all others OFF. Double-click the same chip again → restore all to ON. This is the chart-legend solo pattern (Plotly/Tableau/Grafana) — learnable and fully reversible.
|
||||
- Last-off resets to all-on: disabling the final remaining type snaps all chips back ON. An empty type filter means "no filter", so the list is never mysteriously blank.
|
||||
- Tooltip per chip: "Click to toggle · double-click to isolate".
|
||||
|
||||
## Type mapping (pql → chip)
|
||||
pql ticket types are initiative, epic, story, task, bug (see lib/builtin/tickets/src/ticket_colors.dart). The four chips map as:
|
||||
- Bug → bug
|
||||
- Ticket → story + task (leaf work items)
|
||||
- Epic → epic
|
||||
- Initiative → initiative
|
||||
|
||||
Each chip carries its type-colored dot + border using TicketTypeColors (bug #E87D7D, story/task green/grey, epic #78A0F8, initiative #C792EA).
|
||||
|
||||
## Filtering
|
||||
- Type filter is ANDed with the existing text filter in _TicketsViewState (lib/builtin/tickets/src/tickets_view.dart): a ticket shows only if its type is enabled AND it matches the text filter.
|
||||
- When the type filter hides all items in a status section, that section collapses out (same as text-filter behaviour today).
|
||||
|
||||
## Implementation notes
|
||||
- Active-chip visual: filled tint + type-colored border (active) vs muted/no border (inactive) — reuse the _Toggle pattern from lib/builtin/search/src/search_panel_view.dart and ClideTappable.
|
||||
- Persist nothing across sessions for v1 (always all-on on load); revisit if requested.
|
||||
- a11y: Semantics(button, toggled) per chip, mirroring the search-panel toggle.
|
||||
|
||||
## Wireframe
|
||||
docs/design/wireframes/tickets/ticket-type-filters.json (+ .png export)
|
||||
|
||||
Design revision (2026-06-10, supersedes the chip set/order above): FIVE chips, one per pql type — no story+task grouping. Ordered LARGE→SMALL left to right: Initiative, Epic, Story, Task, Bug. Each maps 1:1 to its pql type (initiative/epic/story/task/bug) with its TicketTypeColors dot+border (initiative #C792EA, epic #78A0F8, story #7DD3A8, task #9AA0AA grey, bug #E87D7D). All five ON by default. Toggle/solo(double-click)/last-off-reset behaviour unchanged. Wireframe updated + approved.', NULL, '2026-06-10 16:06:25', '2026-06-10 16:06:25', '2026-06-10 16:06:25', NULL, '26b5b4d5cf2a48fe6aeacd7b83968848', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:09:21', '2026-06-10 16:09:21', '2026-06-10 16:09:21', NULL, 'c89b4ddcbdeea95483c53b1b76220842', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'in_progress', NULL, '2026-06-10 16:09:26', '2026-06-10 16:09:26', '2026-06-10 16:09:26', NULL, 'c36e6343d44f59ff4af0eea3178b5a94', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'in_progress', 'backlog', NULL, '2026-06-10 16:09:57', '2026-06-10 16:09:57', '2026-06-10 16:09:57', NULL, 'be22c7ff64f106d600b4a5bd70ced1d2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:10:20', '2026-06-10 16:10:20', '2026-06-10 16:10:20', NULL, '6f4b3ce9e2167200ebdde0c1511d4a91', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'ready', 'in_progress', NULL, '2026-06-10 16:10:22', '2026-06-10 16:10:22', '2026-06-10 16:10:22', NULL, 'cce7ce3f17ae087f753f4d6d072a702e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4FDREHRYRR7B9ER72KCQKC', 'status', 'in_progress', 'done', NULL, '2026-06-10 16:12:15', '2026-06-10 16:12:15', '2026-06-10 16:12:15', NULL, '4cb2daa831a115888a9456461bf53bdf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'in_progress', 'in_progress', NULL, '2026-06-10 16:13:50', '2026-06-10 16:13:50', '2026-06-10 16:13:50', NULL, '88bf89e802bd16209db6d2c0a9b963c0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4J5E6W983P1S7BE0FDPSMM', 'status', 'in_progress', 'done', NULL, '2026-06-10 16:15:38', '2026-06-10 16:15:38', '2026-06-10 16:15:38', NULL, '4aa30d9c68463d18f5778cbd20c9da5a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:21:55', '2026-06-10 16:21:55', '2026-06-10 16:21:55', NULL, 'b2f20ad3cb093b7e89342f6b374097ff', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:24', '2026-06-10 16:22:24', '2026-06-10 16:22:24', NULL, 'b88b0e1ef2e1c3711527388dc8d07cce', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:28', '2026-06-10 16:22:28', '2026-06-10 16:22:28', NULL, '4b58326ae217b60be81a07603afee36d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2T11GCV1EV07DYD5BZENTM', 'status', 'backlog', 'ready', NULL, '2026-06-10 16:22:37', '2026-06-10 16:22:37', '2026-06-10 16:22:37', NULL, 'c62c5ac18a88ba763b5194f89a2a7482', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4RD1DDSYM4J7WYEGTXARB4', 'status', 'backlog', 'done', NULL, '2026-06-10 16:34:51', '2026-06-10 16:34:51', '2026-06-10 16:34:51', NULL, '56cde33409eb796ad4fb420ed3033c70', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'status', 'backlog', 'done', NULL, '2026-06-10 18:20:28', '2026-06-10 18:20:28', '2026-06-10 18:20:28', NULL, '213109fcd67b4375ebbd3b59c4a1ed04', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'description', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
|
||||
|
||||
FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
|
||||
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
|
||||
|
||||
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
|
||||
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -149,3 +149,32 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+152
@@ -16,6 +16,158 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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).
|
||||
#
|
||||
# Not bound — no clide command analogue (kept out of scope per the ticket):
|
||||
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
|
||||
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
|
||||
# the current chord matcher (bare/double modifiers unsupported) — tracked by
|
||||
# T-341; Go to File / Find Action below are the practical stand-ins.
|
||||
|
||||
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.
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
|
||||
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]
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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) ---------------------------------
|
||||
- intent: quickOpen.open
|
||||
keys: [ctrl+p, meta+p]
|
||||
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"]
|
||||
@@ -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.3.3"
|
||||
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: >-
|
||||
@@ -207,7 +207,7 @@ dependencies:
|
||||
dev_dependencies:
|
||||
- name: mocktail
|
||||
kind: dart-package
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
homepage: https://pub.dev/packages/mocktail
|
||||
license: MIT
|
||||
purpose: >-
|
||||
@@ -232,7 +232,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
|
||||
|
||||
|
||||
@@ -71,112 +71,112 @@ class ClideTheme {
|
||||
|
||||
// ─── ThemeData ────────────────────────────────────────────────────────
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw color tokens — use when Material widgets can't carry the meaning.
|
||||
|
||||
@@ -63,33 +63,33 @@ class MidnightTheme {
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,33 +65,33 @@ class PaperTheme {
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,34 +65,34 @@ class TerminalTheme {
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF000000),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFF000000),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF000000),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFF000000),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+62
-116
@@ -38,10 +38,7 @@ class _AppRoot extends StatelessWidget {
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: clideName,
|
||||
color: const Color(0xFF000000),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
pageBuilder: (ctx, _, __) => builder(ctx),
|
||||
),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(settings: settings, pageBuilder: (ctx, _, _) => builder(ctx)),
|
||||
home: _RootShell(services: services),
|
||||
);
|
||||
}
|
||||
@@ -240,35 +237,19 @@ class RootLayout extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible && sidebarCollapsed)
|
||||
ClideSpine(
|
||||
label: _sidebarSpineLabel(kernel),
|
||||
side: SpineSide.left,
|
||||
onExpand: () => a.setCollapsed(Slots.sidebar, false),
|
||||
)
|
||||
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
|
||||
else if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.sidebar,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible && contextCollapsed)
|
||||
ClideSpine(
|
||||
label: 'context',
|
||||
side: SpineSide.right,
|
||||
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
|
||||
)
|
||||
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.contextPanel,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
@@ -281,14 +262,18 @@ class RootLayout extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -296,12 +281,18 @@ class RootLayout extends StatelessWidget {
|
||||
// children) so they never shift when a pane collapses (T-294).
|
||||
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar))
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: _BottomRail(slot: Slots.sidebar),
|
||||
)
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
const Expanded(child: StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel))
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: _BottomRail(slot: Slots.contextPanel),
|
||||
)
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
|
||||
@@ -392,11 +383,13 @@ class _RightHatContent extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
]);
|
||||
return Row(
|
||||
children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +410,7 @@ class _WinBtn extends StatelessWidget {
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
|
||||
),
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -544,26 +533,29 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(
|
||||
project: filtered[i],
|
||||
tokens: tokens,
|
||||
onTap: () => _openProject(filtered[i].path),
|
||||
),
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder')),
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder'),
|
||||
),
|
||||
_ActionRow(
|
||||
label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: () => _runFileCommand('file.newWindow')),
|
||||
label: 'New Window',
|
||||
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.newWindow'),
|
||||
),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
|
||||
],
|
||||
@@ -604,8 +596,14 @@ class _RecentProjectRow extends StatelessWidget {
|
||||
// Elide a long path instead of overflowing the row
|
||||
// (matches the welcome recents row; T-160 discipline).
|
||||
Flexible(
|
||||
child: ClideText(project.relativePath,
|
||||
muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
@@ -706,10 +704,7 @@ class _SlotHostState extends State<SlotHost> {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere(
|
||||
(t) => t.id == activeId,
|
||||
orElse: () => tabs.first,
|
||||
);
|
||||
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
@@ -731,21 +726,11 @@ class _SlotBody extends StatelessWidget {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
@@ -757,9 +742,7 @@ class _SlotBody extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [
|
||||
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
|
||||
],
|
||||
items: [for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t))],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
@@ -774,21 +757,12 @@ class _SlotBody extends StatelessWidget {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(
|
||||
key,
|
||||
namespace: ns,
|
||||
placeholder: t.title,
|
||||
);
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
@@ -863,10 +837,7 @@ class _WorkspaceSlot extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: topHeight,
|
||||
child: reveal != null
|
||||
? _RevealedTab(
|
||||
tab: reveal,
|
||||
onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId),
|
||||
)
|
||||
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
|
||||
: topTab.build(ctx),
|
||||
),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
@@ -903,12 +874,7 @@ class _RevealedTab extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
_SlotBody._resolveTitle(context, tab),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.panelHeaderForeground,
|
||||
maxLines: 1,
|
||||
),
|
||||
child: ClideText(_SlotBody._resolveTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
@@ -918,7 +884,7 @@ class _RevealedTab extends StatelessWidget {
|
||||
child: ClideTappable(
|
||||
onTap: onClose,
|
||||
tooltip: 'Close',
|
||||
builder: (_, hovered, __) => Padding(
|
||||
builder: (_, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
@@ -1027,12 +993,7 @@ class _EditorBumpIntent extends Intent {
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
@@ -1042,12 +1003,7 @@ class _ContextSlot extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: active.build(context),
|
||||
);
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,14 +1024,7 @@ class _BottomRail extends StatelessWidget {
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [
|
||||
for (final t in tabs)
|
||||
ClideIconRailItem(
|
||||
id: t.id,
|
||||
icon: _iconFor(slot, t),
|
||||
tooltip: _SlotBody._resolveTitle(ctx, t),
|
||||
),
|
||||
],
|
||||
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: _SlotBody._resolveTitle(ctx, t))],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
@@ -1209,10 +1158,7 @@ class _WelcomeOverlay extends StatelessWidget {
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: const WelcomeView(),
|
||||
);
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -109,12 +103,12 @@ class ClaudeProbe {
|
||||
final String? permissionMode;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
'slash_commands': slashCommands,
|
||||
'skills': skills,
|
||||
if (model != null) 'model': model,
|
||||
if (permissionMode != null) 'permission_mode': permissionMode,
|
||||
};
|
||||
'version': version,
|
||||
'slash_commands': slashCommands,
|
||||
'skills': skills,
|
||||
if (model != null) 'model': model,
|
||||
if (permissionMode != null) 'permission_mode': permissionMode,
|
||||
};
|
||||
|
||||
/// Build from a stream-json `init` event object. Returns null if it doesn't
|
||||
/// look like an init event (no version field).
|
||||
@@ -130,12 +124,12 @@ class ClaudeProbe {
|
||||
}
|
||||
|
||||
static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe(
|
||||
version: (j['version'] as String?) ?? '',
|
||||
slashCommands: _stringList(j['slash_commands']),
|
||||
skills: _stringList(j['skills']),
|
||||
model: j['model'] as String?,
|
||||
permissionMode: j['permission_mode'] as String?,
|
||||
);
|
||||
version: (j['version'] as String?) ?? '',
|
||||
slashCommands: _stringList(j['slash_commands']),
|
||||
skills: _stringList(j['skills']),
|
||||
model: j['model'] as String?,
|
||||
permissionMode: j['permission_mode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const [];
|
||||
@@ -184,13 +178,13 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudeInitProbe? initProbe,
|
||||
ClaudeConfigWatch? watch,
|
||||
Duration debounce = const Duration(milliseconds: 150),
|
||||
}) : _globalDir = globalDir,
|
||||
_cacheDir = cacheDir,
|
||||
_projectDir = projectDir,
|
||||
_versionRunner = versionRunner ?? _defaultVersionRunner,
|
||||
_initProbe = initProbe ?? _defaultInitProbe,
|
||||
_watch = watch,
|
||||
_debounceFor = debounce;
|
||||
}) : _globalDir = globalDir,
|
||||
_cacheDir = cacheDir,
|
||||
_projectDir = projectDir,
|
||||
_versionRunner = versionRunner ?? _defaultVersionRunner,
|
||||
_initProbe = initProbe ?? _defaultInitProbe,
|
||||
_watch = watch,
|
||||
_debounceFor = debounce;
|
||||
|
||||
final Directory _globalDir;
|
||||
final Directory _cacheDir;
|
||||
@@ -400,10 +394,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 +406,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 +418,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 +431,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 +450,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 +547,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 +580,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;
|
||||
|
||||
@@ -218,14 +218,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final managed = orch.byMemberName(memberName);
|
||||
if (managed == null) return;
|
||||
final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}';
|
||||
unawaited(orch.spawn(SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $memberName',
|
||||
// sessionId is a placeholder; real claude session id arrives via init.
|
||||
sessionId: forkId,
|
||||
cwd: managed.cwd,
|
||||
forkSourceSessionId: managed.sessionId,
|
||||
)));
|
||||
unawaited(
|
||||
orch.spawn(
|
||||
SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $memberName',
|
||||
// sessionId is a placeholder; real claude session id arrives via init.
|
||||
sessionId: forkId,
|
||||
cwd: managed.cwd,
|
||||
forkSourceSessionId: managed.sessionId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshStats() async {
|
||||
@@ -276,11 +280,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_MetaRow('sessions', '${latest.sessionCount}'),
|
||||
_MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||
]),
|
||||
if (latest != null)
|
||||
_MetaSection('LIFETIME', [
|
||||
_MetaRow('messages', '${_stats.lifetimeMessages}'),
|
||||
_MetaRow('sessions', '${_stats.lifetimeSessions}'),
|
||||
]),
|
||||
if (latest != null) _MetaSection('LIFETIME', [_MetaRow('messages', '${_stats.lifetimeMessages}'), _MetaRow('sessions', '${_stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
if (sections.isEmpty) {
|
||||
@@ -357,17 +357,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final broker = _orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(
|
||||
model: chatModel,
|
||||
broker: broker,
|
||||
onPopOut: _openChatPane,
|
||||
));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: _openChatPane));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: children,
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
@@ -420,18 +413,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
// 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,
|
||||
),
|
||||
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,
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One key→value row in the pinned SETTINGS table.
|
||||
@@ -454,22 +440,22 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
}
|
||||
|
||||
String _configSectionLabel(_ConfigSection section) => switch (section) {
|
||||
_ConfigSection.skills => 'SKILLS',
|
||||
_ConfigSection.agents => 'AGENTS',
|
||||
_ConfigSection.commands => 'COMMANDS',
|
||||
_ConfigSection.hooks => 'HOOKS',
|
||||
_ConfigSection.permissions => 'PERMISSIONS',
|
||||
_ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
_ConfigSection.skills => 'SKILLS',
|
||||
_ConfigSection.agents => 'AGENTS',
|
||||
_ConfigSection.commands => 'COMMANDS',
|
||||
_ConfigSection.hooks => 'HOOKS',
|
||||
_ConfigSection.permissions => 'PERMISSIONS',
|
||||
_ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
|
||||
int _configSectionCount(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,
|
||||
};
|
||||
_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 _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
|
||||
final expanded = _expanded.contains(section);
|
||||
@@ -492,17 +478,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
|
||||
switch (section) {
|
||||
case _ConfigSection.skills:
|
||||
return [
|
||||
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
|
||||
];
|
||||
return [for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path)];
|
||||
case _ConfigSection.agents:
|
||||
return [
|
||||
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
|
||||
];
|
||||
return [for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path)];
|
||||
case _ConfigSection.commands:
|
||||
return [
|
||||
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
|
||||
];
|
||||
return [for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path)];
|
||||
case _ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
@@ -540,11 +520,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
Widget _configFileRow(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,
|
||||
),
|
||||
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});
|
||||
@@ -558,11 +534,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
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,
|
||||
),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -576,22 +548,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
|
||||
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||
Color kindColor(_ConfigPermKind k) => switch (k) {
|
||||
_ConfigPermKind.allow => tokens.statusSuccess,
|
||||
_ConfigPermKind.ask => tokens.statusWarning,
|
||||
_ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
_ConfigPermKind.allow => tokens.statusSuccess,
|
||||
_ConfigPermKind.ask => tokens.statusWarning,
|
||||
_ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
|
||||
String kindLabel(_ConfigPermKind k) => switch (k) {
|
||||
_ConfigPermKind.allow => kindAllow,
|
||||
_ConfigPermKind.ask => kindAsk,
|
||||
_ConfigPermKind.deny => kindDeny,
|
||||
};
|
||||
_ConfigPermKind.allow => kindAllow,
|
||||
_ConfigPermKind.ask => kindAsk,
|
||||
_ConfigPermKind.deny => kindDeny,
|
||||
};
|
||||
|
||||
final groups = [
|
||||
(_ConfigPermKind.allow, perms.allow),
|
||||
(_ConfigPermKind.ask, perms.ask),
|
||||
(_ConfigPermKind.deny, perms.deny),
|
||||
];
|
||||
final groups = [(_ConfigPermKind.allow, perms.allow), (_ConfigPermKind.ask, perms.ask), (_ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
@@ -631,44 +599,41 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
// --- Shared rendering -----------------------------------------------------
|
||||
|
||||
Widget _placeholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: clideFontSmall),
|
||||
);
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: clideFontSmall),
|
||||
);
|
||||
|
||||
Widget _metaTable(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),
|
||||
));
|
||||
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: _rowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
r.value,
|
||||
fontSize: clideFontSmall,
|
||||
color: r.valueColor ?? tokens.globalForeground,
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelColumnWidth,
|
||||
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,
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +753,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
@@ -840,11 +809,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'Enable bypassPermissions? All tool calls will be auto-allowed.',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
@@ -889,14 +854,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(
|
||||
BuildContext context,
|
||||
SurfaceTokens tokens,
|
||||
ManagedSession managed,
|
||||
bool isVisible,
|
||||
bool isMuted,
|
||||
bool isInjecting,
|
||||
) {
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -975,11 +933,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String _permissionModeBadge(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
///
|
||||
@@ -990,12 +948,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
|
||||
/// 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({
|
||||
required this.mode,
|
||||
required this.tokens,
|
||||
required this.onCycle,
|
||||
required this.onBypass,
|
||||
});
|
||||
const _PermissionModeBadge({required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
@@ -1012,7 +965,8 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
@@ -1046,11 +1000,7 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: 9,
|
||||
color: badgeColor,
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1060,12 +1010,7 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
|
||||
/// A single icon-button used in the roster row controls.
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({
|
||||
required this.painter,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
const _IconButton({required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
@@ -1096,11 +1041,7 @@ class _IconButton extends StatelessWidget {
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent via [_IconButton].
|
||||
class _InjectTextField extends StatelessWidget {
|
||||
const _InjectTextField({
|
||||
required this.controller,
|
||||
required this.tokens,
|
||||
required this.onSubmit,
|
||||
});
|
||||
const _InjectTextField({required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
@@ -1119,12 +1060,7 @@ class _InjectTextField extends StatelessWidget {
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
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,
|
||||
@@ -1139,11 +1075,7 @@ class _InjectTextField extends StatelessWidget {
|
||||
|
||||
/// One row in the TASKS section: status marker + title + owner + reassign.
|
||||
class _TaskRow extends StatelessWidget {
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.members,
|
||||
required this.broker,
|
||||
});
|
||||
const _TaskRow({required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
@@ -1240,18 +1172,9 @@ class _TabStrip extends StatelessWidget {
|
||||
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,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1262,8 +1185,8 @@ class _TabStrip extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _label(SidebarTab t) => switch (t) {
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -195,9 +197,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
sub.cancel();
|
||||
if (!c.isCompleted) c.complete();
|
||||
});
|
||||
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
sub.cancel();
|
||||
});
|
||||
await c.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
sub.cancel();
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
}
|
||||
return _spawn();
|
||||
@@ -265,13 +270,9 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// 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;
|
||||
@@ -289,14 +290,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final resume = await File(transcriptFile).exists();
|
||||
|
||||
try {
|
||||
managed = await orch.spawn(SpawnSpec(
|
||||
id: _orchId,
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
));
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start claude: $e');
|
||||
return;
|
||||
@@ -312,10 +315,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||
final seeded = _conversation?.items.length ?? 0;
|
||||
_kernel()?.log.info(
|
||||
'claude',
|
||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||
);
|
||||
'claude',
|
||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||
);
|
||||
_statusSub = managed.session.statusStream.listen((s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
@@ -424,13 +427,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
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);
|
||||
@@ -477,10 +474,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
|
||||
@@ -505,6 +499,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
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 +508,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 +549,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 +571,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>(
|
||||
id: 'secondary-$index',
|
||||
title: 'session $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index),
|
||||
));
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'session $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
|
||||
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
/// without touching the original.
|
||||
void addFork(String sourceClaudeSessionId) {
|
||||
final index = _nextSecondary++;
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'fork $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
|
||||
));
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'fork $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,12 +8,7 @@ library;
|
||||
import 'dart:convert';
|
||||
|
||||
class DailyActivity {
|
||||
const DailyActivity({
|
||||
required this.date,
|
||||
required this.messageCount,
|
||||
required this.sessionCount,
|
||||
required this.toolCallCount,
|
||||
});
|
||||
const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
|
||||
|
||||
final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
|
||||
final int messageCount;
|
||||
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
|
||||
if (da is List) {
|
||||
for (final e in da) {
|
||||
if (e is! Map) continue;
|
||||
daily.add(DailyActivity(
|
||||
date: '${e['date']}',
|
||||
messageCount: _int(e['messageCount']),
|
||||
sessionCount: _int(e['sessionCount']),
|
||||
toolCallCount: _int(e['toolCallCount']),
|
||||
));
|
||||
daily.add(
|
||||
DailyActivity(
|
||||
date: '${e['date']}',
|
||||
messageCount: _int(e['messageCount']),
|
||||
sessionCount: _int(e['sessionCount']),
|
||||
toolCallCount: _int(e['toolCallCount']),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -328,15 +319,15 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
/// A muted sub-label + hairline divider introducing an [CardSegment] below
|
||||
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
|
||||
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
||||
],
|
||||
),
|
||||
);
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _actions(SurfaceTokens tokens) {
|
||||
final items = <_ActionItem>[];
|
||||
@@ -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,6 +11,7 @@ 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/conversation_card.dart';
|
||||
@@ -33,6 +34,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 +54,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 +181,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>{};
|
||||
@@ -192,6 +206,13 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -285,36 +306,39 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
// visible list don't reattach State to the wrong card (T-285).
|
||||
return switch (g) {
|
||||
StickyItem(:final item) => _ConversationTurn(
|
||||
key: ValueKey('turn.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
collapseTools: true,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
key: ValueKey('turn.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
collapseTools: true,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
FoldedCluster(:final items) => _ActivityCard(
|
||||
key: ValueKey('cluster.${items.first.uuid}'),
|
||||
items: items,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
key: ValueKey('cluster.${items.first.uuid}'),
|
||||
items: items,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
EditRun(:final edits) => _EditRunCard(
|
||||
key: ValueKey('edits.${edits.first.uuid}'),
|
||||
edits: edits,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
key: ValueKey('edits.${edits.first.uuid}'),
|
||||
edits: edits,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
@@ -365,6 +389,31 @@ 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}));
|
||||
}
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
class _ConversationTurn extends StatelessWidget {
|
||||
const _ConversationTurn({
|
||||
@@ -373,6 +422,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 +443,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;
|
||||
|
||||
@@ -418,55 +472,63 @@ class _ConversationTurn extends StatelessWidget {
|
||||
// sidechain prompt here is an orphan one (its Agent card couldn't be
|
||||
// resolved) — folded prompts are suppressed upstream (T-263).
|
||||
UserMessage() when i.injected || i.isSidechain => ConversationCard(
|
||||
// Framed like every other card (T-306) — just muted + collapsed, not
|
||||
// the blue "you" accent (D-78); bare read as unfinished next to the
|
||||
// carded tool calls.
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: i.isSidechain ? 'agent prompt' : 'context',
|
||||
copyText: i.text,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.text),
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
// Framed like every other card (T-306) — just muted + collapsed, not
|
||||
// the blue "you" accent (D-78); bare read as unfinished next to the
|
||||
// carded tool calls.
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: i.isSidechain ? 'agent prompt' : 'context',
|
||||
copyText: i.text,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.text),
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
UserMessage() => ConversationCard(
|
||||
accent: tokens.globalFocus,
|
||||
label: 'you',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
// Pasted-image @path tokens render as inline thumbnails that open the
|
||||
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
|
||||
body: ClideMarkdown(
|
||||
i.text,
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
),
|
||||
accent: tokens.globalFocus,
|
||||
label: 'you',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
// Pasted-image @path tokens render as inline thumbnails that open the
|
||||
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
|
||||
body: ClideMarkdown(
|
||||
i.text,
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
// agent with a muted accent, never the coral "claude" brand (T-265). The
|
||||
// coral claudeAccent is reserved for the real main-thread Claude.
|
||||
AssistantTextMessage() => ConversationCard(
|
||||
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
|
||||
label: i.isSidechain ? 'agent' : 'claude',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
|
||||
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
|
||||
label: i.isSidechain ? 'agent' : 'claude',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideMarkdown(
|
||||
i.text,
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
AssistantThinkingMessage() => ConversationCard(
|
||||
// Framed + muted like the context card (T-306).
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: i.isSidechain ? 'agent thinking' : 'thinking',
|
||||
copyText: i.thinking,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.thinking),
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
// Framed + muted like the context card (T-306).
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: i.isSidechain ? 'agent thinking' : 'thinking',
|
||||
copyText: i.thinking,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.thinking),
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
AssistantToolUse() => collapseTools ? _toolUseCollapser(i) : _toolContentCard(i),
|
||||
ToolResultMessage() => _toolResult(i),
|
||||
ImageMessage() => _image(context, i),
|
||||
@@ -502,16 +564,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)],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -519,34 +578,30 @@ class _ConversationTurn extends StatelessWidget {
|
||||
|
||||
void _openLightbox(BuildContext context, String path) {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
|
||||
),
|
||||
),
|
||||
);
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder(String path) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// A standalone tool use (T-305): every tool use is a collapser over a
|
||||
/// one-item list. The collapser carries the echoed last line, the count, and
|
||||
@@ -585,6 +640,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
item: r,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
@@ -620,8 +676,15 @@ 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)),
|
||||
),
|
||||
];
|
||||
|
||||
// A resolved permission-prompted call is tinted green if approved / red if
|
||||
@@ -675,23 +738,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 +776,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 +807,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 +817,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 +838,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
@@ -809,6 +871,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 +881,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 +902,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;
|
||||
@@ -47,270 +48,259 @@ class ClaudeExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'claude.primary',
|
||||
slot: Slots.workspace,
|
||||
title: 'Claude',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 90,
|
||||
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.new-secondary',
|
||||
command: 'claude.new-secondary',
|
||||
title: 'Claude: open a secondary session',
|
||||
run: (_) async {
|
||||
_hostKey.currentState?.addSecondary();
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.kill-all-sessions',
|
||||
command: 'claude.kill-all-sessions',
|
||||
title: 'Claude: kill all sessions for this repo',
|
||||
run: _killAllSessions,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.session-storage',
|
||||
command: 'claude.session-storage',
|
||||
title: 'Claude: session storage (disk usage + cleanup)',
|
||||
run: _manageStorage,
|
||||
),
|
||||
// T-235: cycle how aggressively the activity card folds meta steps
|
||||
// (none → tools → thinking → everything), persisted app-wide. The panes
|
||||
// read kActivityFoldLevelKey and rebuild via the settings notifier.
|
||||
CommandContribution(
|
||||
id: 'claude.activity.fold-level',
|
||||
command: 'claude.activity.fold-level',
|
||||
title: 'Claude: cycle activity fold level',
|
||||
run: _cycleFoldLevel,
|
||||
),
|
||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.show <sessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.agent.show',
|
||||
command: 'claude.agent.show',
|
||||
title: 'Claude: show an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.show(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.hide',
|
||||
command: 'claude.agent.hide',
|
||||
title: 'Claude: hide an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.hide(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.close',
|
||||
command: 'claude.agent.close',
|
||||
title: 'Claude: close (kill) an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
await _orchestrator?.close(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.mute',
|
||||
command: 'claude.agent.mute',
|
||||
title: 'Claude: mute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.mute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.unmute',
|
||||
command: 'claude.agent.unmute',
|
||||
title: 'Claude: unmute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.unmute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.agent.inject-message <sessionId> <text...>
|
||||
CommandContribution(
|
||||
id: 'claude.agent.inject-message',
|
||||
command: 'claude.agent.inject-message',
|
||||
title: 'Claude: inject a text turn into an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
final text = args.skip(1).join(' ');
|
||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
||||
_orchestrator?.injectMessage(id, text);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
),
|
||||
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
|
||||
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
|
||||
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
|
||||
// UI's confirm dialog; the CLI caller is responsible for their own safety.
|
||||
CommandContribution(
|
||||
id: 'claude.agent.set-permission-mode',
|
||||
command: 'claude.agent.set-permission-mode',
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
},
|
||||
),
|
||||
// T-226: cycle the primary session's permission mode through the safe
|
||||
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
|
||||
CommandContribution(
|
||||
id: 'claude.mode.cycle',
|
||||
command: 'claude.mode.cycle',
|
||||
title: 'Claude: Cycle permission mode',
|
||||
run: (_) async {
|
||||
final managed = _orchestrator?.byId('primary');
|
||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||
managed.session.setPermissionMode(next);
|
||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.task.reassign <taskId> <toSessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.task.reassign',
|
||||
command: 'claude.task.reassign',
|
||||
title: 'Claude: reassign a shared task to an agent',
|
||||
run: (args) async {
|
||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
||||
final taskId = args[0];
|
||||
final toId = args[1];
|
||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||
},
|
||||
),
|
||||
// T-180: full team chat pane opened as a workspace tab.
|
||||
// Shares the TeamChatModel with the sidebar widget.
|
||||
TabContribution(
|
||||
id: 'claude.team-chat',
|
||||
slot: Slots.workspace,
|
||||
title: 'Team Chat',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 85,
|
||||
build: (_) {
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) return const SizedBox.shrink();
|
||||
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
|
||||
},
|
||||
),
|
||||
// CLI parity: open the team chat pane from the shell.
|
||||
// Usage: clide claude.team-chat.open
|
||||
CommandContribution(
|
||||
id: 'claude.team-chat.open',
|
||||
command: 'claude.team-chat.open',
|
||||
title: 'Claude: open the team chat pane',
|
||||
run: (args) async {
|
||||
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.team-chat.post [@name] <text...>
|
||||
// Posts a message into the broker channel as the user.
|
||||
// Leading @name tag selects the recipient; omit for broadcast.
|
||||
CommandContribution(
|
||||
id: 'claude.team-chat.post',
|
||||
command: 'claude.team-chat.post',
|
||||
title: 'Claude: post a message into the team channel as the user',
|
||||
run: (args) async {
|
||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
||||
final raw = args.join(' ');
|
||||
String? recipient;
|
||||
String body = raw;
|
||||
if (raw.startsWith('@')) {
|
||||
final ws = raw.indexOf(RegExp(r'\s'));
|
||||
if (ws > 0) {
|
||||
final tag = raw.substring(1, ws);
|
||||
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
|
||||
body = raw.substring(ws).trim();
|
||||
}
|
||||
}
|
||||
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
|
||||
},
|
||||
),
|
||||
// claude.agent.fork: branch a managed session into a new fork session
|
||||
// (T-172, D-6 CLI/UI parity for the roster fork button).
|
||||
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
|
||||
// <sourceSessionId>: the clide-internal id of the session to fork.
|
||||
// <cwd>: optional working directory; defaults to the source session's cwd.
|
||||
CommandContribution(
|
||||
id: 'claude.agent.fork',
|
||||
command: 'claude.agent.fork',
|
||||
title: 'Claude: fork a managed session into a new branch session',
|
||||
run: (args) async {
|
||||
final sourceId = args.firstOrNull;
|
||||
if (sourceId == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||
}
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
||||
}
|
||||
final source = orch.byId(sourceId);
|
||||
if (source == null) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $sourceId',
|
||||
sessionId: forkId,
|
||||
cwd: cwd,
|
||||
forkSourceSessionId: source.sessionId,
|
||||
));
|
||||
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
// Always-pickable left-panel tab: Claude activity (from
|
||||
// stats-cache.json) + the team roster when a team is running (T-141).
|
||||
TabContribution(
|
||||
id: 'claude.meta',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Activity',
|
||||
icon: PhosphorIcons.byName('robot'),
|
||||
priority: 60,
|
||||
build: (_) => const ClaudeMetaSidebar(),
|
||||
),
|
||||
// In-pane status slot (T-145): the active Claude pane publishes
|
||||
// its model · permission-mode · context line here.
|
||||
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
|
||||
// yields width under pressure and ClideMarquee scrolls (T-160).
|
||||
StatusItemContribution(
|
||||
id: 'claude.status-context',
|
||||
priority: 50,
|
||||
flex: 1,
|
||||
build: (_) => const PaneContextStatusItem(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'claude.primary',
|
||||
slot: Slots.workspace,
|
||||
title: 'Claude',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 90,
|
||||
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.new-secondary',
|
||||
command: 'claude.new-secondary',
|
||||
title: 'Claude: open a secondary session',
|
||||
run: (_) async {
|
||||
_hostKey.currentState?.addSecondary();
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.kill-all-sessions',
|
||||
command: 'claude.kill-all-sessions',
|
||||
title: 'Claude: kill all sessions for this repo',
|
||||
run: _killAllSessions,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.session-storage',
|
||||
command: 'claude.session-storage',
|
||||
title: 'Claude: session storage (disk usage + cleanup)',
|
||||
run: _manageStorage,
|
||||
),
|
||||
// T-235: cycle how aggressively the activity card folds meta steps
|
||||
// (none → tools → thinking → everything), persisted app-wide. The panes
|
||||
// read kActivityFoldLevelKey and rebuild via the settings notifier.
|
||||
CommandContribution(
|
||||
id: 'claude.activity.fold-level',
|
||||
command: 'claude.activity.fold-level',
|
||||
title: 'Claude: cycle activity fold level',
|
||||
run: _cycleFoldLevel,
|
||||
),
|
||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.show <sessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.agent.show',
|
||||
command: 'claude.agent.show',
|
||||
title: 'Claude: show an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.show(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.hide',
|
||||
command: 'claude.agent.hide',
|
||||
title: 'Claude: hide an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.hide(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.close',
|
||||
command: 'claude.agent.close',
|
||||
title: 'Claude: close (kill) an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
await _orchestrator?.close(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.mute',
|
||||
command: 'claude.agent.mute',
|
||||
title: 'Claude: mute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.mute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.agent.unmute',
|
||||
command: 'claude.agent.unmute',
|
||||
title: 'Claude: unmute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
_orchestrator?.unmute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.agent.inject-message <sessionId> <text...>
|
||||
CommandContribution(
|
||||
id: 'claude.agent.inject-message',
|
||||
command: 'claude.agent.inject-message',
|
||||
title: 'Claude: inject a text turn into an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
final text = args.skip(1).join(' ');
|
||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
||||
_orchestrator?.injectMessage(id, text);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
),
|
||||
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
|
||||
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
|
||||
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
|
||||
// UI's confirm dialog; the CLI caller is responsible for their own safety.
|
||||
CommandContribution(
|
||||
id: 'claude.agent.set-permission-mode',
|
||||
command: 'claude.agent.set-permission-mode',
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
},
|
||||
),
|
||||
// T-226: cycle the primary session's permission mode through the safe
|
||||
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
|
||||
CommandContribution(
|
||||
id: 'claude.mode.cycle',
|
||||
command: 'claude.mode.cycle',
|
||||
title: 'Claude: Cycle permission mode',
|
||||
run: (_) async {
|
||||
final managed = _orchestrator?.byId('primary');
|
||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||
managed.session.setPermissionMode(next);
|
||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.task.reassign <taskId> <toSessionId>
|
||||
CommandContribution(
|
||||
id: 'claude.task.reassign',
|
||||
command: 'claude.task.reassign',
|
||||
title: 'Claude: reassign a shared task to an agent',
|
||||
run: (args) async {
|
||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
||||
final taskId = args[0];
|
||||
final toId = args[1];
|
||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||
},
|
||||
),
|
||||
// T-180: full team chat pane opened as a workspace tab.
|
||||
// Shares the TeamChatModel with the sidebar widget.
|
||||
TabContribution(
|
||||
id: 'claude.team-chat',
|
||||
slot: Slots.workspace,
|
||||
title: 'Team Chat',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 85,
|
||||
build: (_) {
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) return const SizedBox.shrink();
|
||||
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
|
||||
},
|
||||
),
|
||||
// CLI parity: open the team chat pane from the shell.
|
||||
// Usage: clide claude.team-chat.open
|
||||
CommandContribution(
|
||||
id: 'claude.team-chat.open',
|
||||
command: 'claude.team-chat.open',
|
||||
title: 'Claude: open the team chat pane',
|
||||
run: (args) async {
|
||||
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
|
||||
},
|
||||
),
|
||||
// Usage: clide claude.team-chat.post [@name] <text...>
|
||||
// Posts a message into the broker channel as the user.
|
||||
// Leading @name tag selects the recipient; omit for broadcast.
|
||||
CommandContribution(
|
||||
id: 'claude.team-chat.post',
|
||||
command: 'claude.team-chat.post',
|
||||
title: 'Claude: post a message into the team channel as the user',
|
||||
run: (args) async {
|
||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
||||
final raw = args.join(' ');
|
||||
String? recipient;
|
||||
String body = raw;
|
||||
if (raw.startsWith('@')) {
|
||||
final ws = raw.indexOf(RegExp(r'\s'));
|
||||
if (ws > 0) {
|
||||
final tag = raw.substring(1, ws);
|
||||
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
|
||||
body = raw.substring(ws).trim();
|
||||
}
|
||||
}
|
||||
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
|
||||
},
|
||||
),
|
||||
// claude.agent.fork: branch a managed session into a new fork session
|
||||
// (T-172, D-6 CLI/UI parity for the roster fork button).
|
||||
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
|
||||
// <sourceSessionId>: the clide-internal id of the session to fork.
|
||||
// <cwd>: optional working directory; defaults to the source session's cwd.
|
||||
CommandContribution(
|
||||
id: 'claude.agent.fork',
|
||||
command: 'claude.agent.fork',
|
||||
title: 'Claude: fork a managed session into a new branch session',
|
||||
run: (args) async {
|
||||
final sourceId = args.firstOrNull;
|
||||
if (sourceId == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||
}
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
||||
}
|
||||
final source = orch.byId(sourceId);
|
||||
if (source == null) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
|
||||
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
// Always-pickable left-panel tab: Claude activity (from
|
||||
// stats-cache.json) + the team roster when a team is running (T-141).
|
||||
TabContribution(
|
||||
id: 'claude.meta',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Activity',
|
||||
icon: PhosphorIcons.byName('robot'),
|
||||
priority: 60,
|
||||
build: (_) => const ClaudeMetaSidebar(),
|
||||
),
|
||||
// In-pane status slot (T-145): the active Claude pane publishes
|
||||
// its model · permission-mode · context line here.
|
||||
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
|
||||
// yields width under pressure and ClideMarquee scrolls (T-160).
|
||||
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
@@ -348,6 +338,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 +388,15 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(ImageMessage(
|
||||
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
));
|
||||
target.conversation.inject(
|
||||
ImageMessage(
|
||||
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -452,9 +457,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'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,11 @@ import 'package:flutter/widgets.dart';
|
||||
/// Open [path] full-size in the lightbox via the kernel dialog router.
|
||||
void openImageLightbox(BuildContext context, String path) {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
|
||||
),
|
||||
),
|
||||
);
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context, double size) {
|
||||
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -67,24 +67,24 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
}
|
||||
|
||||
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
|
||||
for (final m in kSafePermissionCycle)
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon(m),
|
||||
color: permissionModeColor(m, tokens),
|
||||
label: permissionModeLabel(m),
|
||||
active: m == widget.mode,
|
||||
onSelect: () => widget.onSelect(m),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon('bypassPermissions'),
|
||||
color: permissionModeColor('bypassPermissions', tokens),
|
||||
label: permissionModeLabel('bypassPermissions'),
|
||||
enabled: false,
|
||||
active: widget.mode == 'bypassPermissions',
|
||||
onSelect: () {},
|
||||
),
|
||||
];
|
||||
for (final m in kSafePermissionCycle)
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon(m),
|
||||
color: permissionModeColor(m, tokens),
|
||||
label: permissionModeLabel(m),
|
||||
active: m == widget.mode,
|
||||
onSelect: () => widget.onSelect(m),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
ClideMenuItem(
|
||||
leading: permissionModeIcon('bypassPermissions'),
|
||||
color: permissionModeColor('bypassPermissions', tokens),
|
||||
label: permissionModeLabel('bypassPermissions'),
|
||||
enabled: false,
|
||||
active: widget.mode == 'bypassPermissions',
|
||||
onSelect: () {},
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -94,11 +94,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(
|
||||
onClose: ctrl.close,
|
||||
minWidth: 180,
|
||||
entries: _entries(ClideTheme.of(ctx).surface),
|
||||
),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
border: Border.all(
|
||||
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
|
||||
),
|
||||
border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
|
||||
|
||||
@@ -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: [
|
||||
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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: [
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step = _q.length - 1)),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
|
||||
]),
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step = _q.length - 1)),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
Row(
|
||||
children: [
|
||||
if (_step > 0) ...[ClideButton(label: '‹ Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
|
||||
ClideButton(label: last ? 'Review ›' : 'Next ›', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
],
|
||||
ClideButton(
|
||||
label: last ? 'Review ›' : 'Next ›',
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: answered ? () => setState(() => _step++) : null,
|
||||
),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
));
|
||||
chips.add(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18),
|
||||
border: Border.all(color: tokens.statusInfo),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
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,
|
||||
[
|
||||
for (final o in (q['options'] as List? ?? const []))
|
||||
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
|
||||
],
|
||||
),
|
||||
_Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
|
||||
for (final o in (q['options'] as List? ?? const []))
|
||||
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,13 +13,7 @@ import 'dart:io';
|
||||
|
||||
/// One session in the workspace, summarised for the picker.
|
||||
class SessionSummary {
|
||||
const SessionSummary({
|
||||
required this.id,
|
||||
required this.modified,
|
||||
this.firstUser,
|
||||
this.lastUser,
|
||||
this.sizeBytes = 0,
|
||||
});
|
||||
const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
|
||||
|
||||
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
|
||||
final String id;
|
||||
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
|
||||
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
|
||||
/// capped at [max]. Each is summarised by bookend user prompts read from a
|
||||
/// bounded [window] at each end of its transcript.
|
||||
Future<List<SessionSummary>> listSessions(
|
||||
Directory dir, {
|
||||
int max = 20,
|
||||
int window = 128 * 1024,
|
||||
}) async {
|
||||
Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
|
||||
if (!await dir.exists()) return const [];
|
||||
final files = <File>[];
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
|
||||
final stat = await f.stat();
|
||||
final bookends = await _bookends(f, window);
|
||||
final id = _sessionId(f.path);
|
||||
summaries.add(SessionSummary(
|
||||
id: id,
|
||||
modified: stat.modified,
|
||||
firstUser: bookends.first,
|
||||
lastUser: bookends.last,
|
||||
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
|
||||
));
|
||||
summaries.add(
|
||||
SessionSummary(
|
||||
id: id,
|
||||
modified: stat.modified,
|
||||
firstUser: bookends.first,
|
||||
lastUser: bookends.last,
|
||||
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
|
||||
),
|
||||
);
|
||||
}
|
||||
summaries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return summaries.length > max ? summaries.sublist(0, max) : summaries;
|
||||
|
||||
@@ -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;
|
||||
@@ -181,9 +174,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
|
||||
/// Just the sessions a pane should currently render.
|
||||
List<ManagedSession> get visibleSessions => [
|
||||
for (final m in _sessions.values)
|
||||
if (m.visible) m,
|
||||
];
|
||||
for (final m in _sessions.values)
|
||||
if (m.visible) m,
|
||||
];
|
||||
|
||||
ManagedSession? byId(String id) => _sessions[id];
|
||||
|
||||
@@ -224,18 +217,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 +346,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])),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -40,11 +40,7 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
|
||||
/// 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',
|
||||
[
|
||||
@@ -176,16 +172,22 @@ final class AllowTool extends ToolDecision {
|
||||
final String? followUpNote;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'behavior': 'allow',
|
||||
'updatedInput': updatedInput,
|
||||
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
|
||||
};
|
||||
'behavior': 'allow',
|
||||
'updatedInput': updatedInput,
|
||||
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||
///
|
||||
/// [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};
|
||||
}
|
||||
@@ -246,9 +248,16 @@ 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.
|
||||
@@ -297,15 +306,17 @@ class StreamJsonSession {
|
||||
// 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({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,15 +446,17 @@ class StreamJsonSession {
|
||||
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
||||
final tuid = request['tool_use_id'] as String? ?? '';
|
||||
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
|
||||
_queue.add(ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
description: request['description'] as String?,
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
|
||||
));
|
||||
_queue.add(
|
||||
ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
description: request['description'] as String?,
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
|
||||
),
|
||||
);
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
}
|
||||
@@ -452,10 +465,12 @@ class StreamJsonSession {
|
||||
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
|
||||
return;
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
|
||||
@@ -476,14 +491,16 @@ class StreamJsonSession {
|
||||
} else {
|
||||
mcpResponse = await _dispatchMcp(server, message);
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {
|
||||
'subtype': 'success',
|
||||
'request_id': rid,
|
||||
'response': {'mcp_response': mcpResponse}
|
||||
},
|
||||
}));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {
|
||||
'subtype': 'success',
|
||||
'request_id': rid,
|
||||
'response': {'mcp_response': mcpResponse},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
McpServer? _mcpServerNamed(String? name) {
|
||||
@@ -542,11 +559,16 @@ 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({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
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) {
|
||||
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
||||
// compact record of an answered question in the conversation log (D-78).
|
||||
@@ -636,16 +658,13 @@ class StreamJsonSession {
|
||||
/// so it renders immediately (stream-json doesn't replay stdin without
|
||||
/// `--replay-user-messages`).
|
||||
void send(String text) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'user',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
}));
|
||||
_items.add(UserMessage(
|
||||
uuid: 'local-${_localSeq++}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
text: text,
|
||||
));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'user',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
}),
|
||||
);
|
||||
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
@@ -653,11 +672,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({
|
||||
'type': 'control_request',
|
||||
'request_id': 'interrupt-${_localSeq++}',
|
||||
'request': {'subtype': 'interrupt'},
|
||||
}));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'interrupt-${_localSeq++}',
|
||||
'request': {'subtype': 'interrupt'},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the session's permission mode (T-181, D-77). Sends a
|
||||
@@ -669,11 +690,13 @@ class StreamJsonSession {
|
||||
/// cockpit badge's plain click; bypassPermissions is reachable only via a
|
||||
/// confirmed shift-click (T-181).
|
||||
void setPermissionMode(String mode) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'set-perm-${_localSeq++}',
|
||||
'request': {'subtype': 'set_permission_mode', 'mode': mode},
|
||||
}));
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'set-perm-${_localSeq++}',
|
||||
'request': {'subtype': 'set_permission_mode', 'mode': mode},
|
||||
}),
|
||||
);
|
||||
// Optimistically reflect the change so the badge / status line update
|
||||
// immediately (T-250) — the control_request emits no status event, and a
|
||||
// fresh system/init only arrives later. The next init reconciles if the
|
||||
|
||||
@@ -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,25 +345,26 @@ class TeamMcpServer implements McpServer {
|
||||
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
|
||||
case 'task_status':
|
||||
return _result(
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
|
||||
);
|
||||
default:
|
||||
return _error('Unknown team tool: $name');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _result(Map<String, dynamic> value) => {
|
||||
'content': [
|
||||
{'type': 'text', 'text': jsonEncode(value)},
|
||||
],
|
||||
'isError': value['ok'] == false,
|
||||
};
|
||||
'content': [
|
||||
{'type': 'text', 'text': jsonEncode(value)},
|
||||
],
|
||||
'isError': value['ok'] == false,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _error(String message) => {
|
||||
'content': [
|
||||
{'type': 'text', 'text': message},
|
||||
],
|
||||
'isError': true,
|
||||
};
|
||||
'content': [
|
||||
{'type': 'text', 'text': message},
|
||||
],
|
||||
'isError': true,
|
||||
};
|
||||
}
|
||||
|
||||
const _toolDefs = <Map<String, dynamic>>[
|
||||
|
||||
@@ -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 …'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -439,8 +391,8 @@ class _ChatRow extends StatelessWidget {
|
||||
final toLabel = message.broadcast
|
||||
? '→ all'
|
||||
: message.to != null
|
||||
? '→ ${message.to}'
|
||||
: null;
|
||||
? '→ ${message.to}'
|
||||
: null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
margin: const EdgeInsets.only(right: 5, top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: senderColor.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
|
||||
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
|
||||
),
|
||||
if (toLabel != null)
|
||||
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
|
||||
|
||||
/// Inline text input for the chat composer.
|
||||
class _ChatInputField extends StatelessWidget {
|
||||
const _ChatInputField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.tokens,
|
||||
required this.onSubmit,
|
||||
required this.placeholder,
|
||||
});
|
||||
const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalForeground,
|
||||
height: 1.4,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -38,11 +38,11 @@ abstract final class ClaudeConversation {
|
||||
|
||||
/// Encode [status] for [agentId] as a [memberStatusChannel] message body.
|
||||
static Map<String, Object?> memberStatusData(String agentId, SessionStatus status) => {
|
||||
'agentId': agentId,
|
||||
if (status.model != null) 'model': status.model,
|
||||
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
|
||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||
};
|
||||
'agentId': agentId,
|
||||
if (status.model != null) 'model': status.model,
|
||||
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
|
||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
class TranscriptPublisher {
|
||||
@@ -50,16 +50,11 @@ class TranscriptPublisher {
|
||||
/// [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 {
|
||||
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,
|
||||
});
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
@@ -229,11 +229,11 @@ class TranscriptReader {
|
||||
String? projectsBase,
|
||||
int? initialTailBytes,
|
||||
String? file,
|
||||
}) : _pollInterval = pollInterval,
|
||||
_onWarn = onWarn ?? _defaultWarn,
|
||||
_projectsBase = projectsBase ?? _defaultProjectsBase(),
|
||||
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
|
||||
_explicitFile = file;
|
||||
}) : _pollInterval = pollInterval,
|
||||
_onWarn = onWarn ?? _defaultWarn,
|
||||
_projectsBase = projectsBase ?? _defaultProjectsBase(),
|
||||
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
|
||||
_explicitFile = file;
|
||||
|
||||
final String workspacePath;
|
||||
final Duration _pollInterval;
|
||||
@@ -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;
|
||||
@@ -462,13 +455,13 @@ class SessionStatus {
|
||||
|
||||
/// Overlay [other]'s non-null fields onto this one.
|
||||
SessionStatus merge(SessionStatus other) => SessionStatus(
|
||||
model: other.model ?? model,
|
||||
permissionMode: other.permissionMode ?? permissionMode,
|
||||
contextTokens: other.contextTokens ?? contextTokens,
|
||||
cost: other.cost ?? cost,
|
||||
contextWindow: other.contextWindow ?? contextWindow,
|
||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||
);
|
||||
model: other.model ?? model,
|
||||
permissionMode: other.permissionMode ?? permissionMode,
|
||||
contextTokens: other.contextTokens ?? contextTokens,
|
||||
cost: other.cost ?? cost,
|
||||
contextWindow: other.contextWindow ?? contextWindow,
|
||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -496,13 +489,13 @@ class _StatusAcc {
|
||||
int? contextWindow;
|
||||
String? rateLimitInfo;
|
||||
SessionStatus toStatus() => SessionStatus(
|
||||
model: model,
|
||||
permissionMode: permissionMode,
|
||||
contextTokens: contextTokens,
|
||||
cost: cost,
|
||||
contextWindow: contextWindow,
|
||||
rateLimitInfo: rateLimitInfo,
|
||||
);
|
||||
model: model,
|
||||
permissionMode: permissionMode,
|
||||
contextTokens: contextTokens,
|
||||
cost: cost,
|
||||
contextWindow: contextWindow,
|
||||
rateLimitInfo: rateLimitInfo,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||
isError: item['is_error'] as bool? ?? false,
|
||||
));
|
||||
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));
|
||||
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, thinking: thinking));
|
||||
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,
|
||||
toolUseId: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||
));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,30 +61,23 @@ class CliInstallExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'clide.installCli',
|
||||
command: 'clide.installCli',
|
||||
title: "clide: Install 'clide' command in PATH",
|
||||
run: (_) async {
|
||||
final r = _resolved.install();
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
CommandContribution(
|
||||
id: 'clide.installCli',
|
||||
command: 'clide.installCli',
|
||||
title: "clide: Install 'clide' command in PATH",
|
||||
run: (_) async {
|
||||
final r = _resolved.install();
|
||||
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});
|
||||
}
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
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;
|
||||
final Color rejected;
|
||||
|
||||
Color forType(String? type) => switch (type) {
|
||||
'confirmed' => confirmed,
|
||||
'question' => question,
|
||||
'rejected' => rejected,
|
||||
_ => confirmed,
|
||||
};
|
||||
'confirmed' => confirmed,
|
||||
'question' => question,
|
||||
'rejected' => rejected,
|
||||
_ => 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,12 +139,14 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter
|
||||
? _decisions
|
||||
.where((d) =>
|
||||
d.id.toLowerCase().contains(lf) ||
|
||||
d.title.toLowerCase().contains(lf) ||
|
||||
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||
(d.type ?? '').contains(lf))
|
||||
.toList()
|
||||
.where(
|
||||
(d) =>
|
||||
d.id.toLowerCase().contains(lf) ||
|
||||
d.title.toLowerCase().contains(lf) ||
|
||||
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||
(d.type ?? '').contains(lf),
|
||||
)
|
||||
.toList()
|
||||
: _decisions;
|
||||
|
||||
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -225,12 +264,12 @@ class _DecisionEntry {
|
||||
final String? status;
|
||||
|
||||
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
domain: json['domain'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
domain: json['domain'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _DecisionCard extends StatelessWidget {
|
||||
@@ -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),
|
||||
|
||||
@@ -37,21 +37,21 @@ class DecisionsExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'decisions.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Decisions',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('lightbulb'),
|
||||
build: (_) => const DecisionsView(),
|
||||
),
|
||||
TabContribution(
|
||||
id: 'decisions.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Decision',
|
||||
icon: PhosphorIcons.byName('lightbulb'),
|
||||
build: (_) => const DecisionDetailView(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'decisions.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Decisions',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('lightbulb'),
|
||||
build: (_) => const DecisionsView(),
|
||||
),
|
||||
TabContribution(
|
||||
id: 'decisions.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Decision',
|
||||
icon: PhosphorIcons.byName('lightbulb'),
|
||||
build: (_) => const DecisionDetailView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ class DeepLinkAction {
|
||||
|
||||
/// A human-readable description for the confirmation prompt.
|
||||
String get describe => switch (name) {
|
||||
'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
|
||||
_ => name,
|
||||
};
|
||||
'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
|
||||
_ => name,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse [url] into a [DeepLinkAction], or null when it is malformed, not a
|
||||
|
||||
@@ -25,13 +25,8 @@ 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
|
||||
Future<void> activate(ClideExtensionContext ctx) async => _ctx = ctx;
|
||||
|
||||
@@ -15,101 +15,54 @@ 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,
|
||||
),
|
||||
// Collapse toggles (D-051, D-054)
|
||||
CommandContribution(
|
||||
id: 'sidebar.collapse',
|
||||
command: 'sidebar.collapse',
|
||||
title: 'Toggle Sidebar Collapse',
|
||||
defaultBinding: 'ctrl+shift+1',
|
||||
run: _collapseSidebar,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'context.collapse',
|
||||
command: 'context.collapse',
|
||||
title: 'Toggle Context Panel Collapse',
|
||||
defaultBinding: 'ctrl+shift+3',
|
||||
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,
|
||||
),
|
||||
// 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.exit',
|
||||
command: 'panel.focusMode.exit',
|
||||
title: 'Exit Focus Mode',
|
||||
defaultBinding: 'escape',
|
||||
// Stand down in Vim insert/visual mode so Esc returns to normal
|
||||
// mode instead of closing the editor (T-257). Symmetric with
|
||||
// vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
|
||||
bindingWhen: '!vim.insert && !vim.visual',
|
||||
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,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
id: 'sidebar.section.${i + 1}',
|
||||
command: 'sidebar.section.${i + 1}',
|
||||
title: 'Sidebar: Section ${i + 1}',
|
||||
defaultBinding: 'alt+${i + 1}',
|
||||
run: (args) => _switchSidebarSection(i),
|
||||
),
|
||||
];
|
||||
_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),
|
||||
// Collapse toggles (D-051, D-054)
|
||||
CommandContribution(
|
||||
id: 'sidebar.collapse',
|
||||
command: 'sidebar.collapse',
|
||||
title: 'Toggle Sidebar Collapse',
|
||||
defaultBinding: 'ctrl+shift+1',
|
||||
run: _collapseSidebar,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'context.collapse',
|
||||
command: 'context.collapse',
|
||||
title: 'Toggle Context Panel Collapse',
|
||||
defaultBinding: 'ctrl+shift+3',
|
||||
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),
|
||||
// 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.exit',
|
||||
command: 'panel.focusMode.exit',
|
||||
title: 'Exit Focus Mode',
|
||||
defaultBinding: 'escape',
|
||||
// Stand down in Vim insert/visual mode so Esc returns to normal
|
||||
// mode instead of closing the editor (T-257). Symmetric with
|
||||
// vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
|
||||
bindingWhen: '!vim.insert && !vim.visual',
|
||||
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),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
id: 'sidebar.section.${i + 1}',
|
||||
command: 'sidebar.section.${i + 1}',
|
||||
title: 'Sidebar: Section ${i + 1}',
|
||||
defaultBinding: 'alt+${i + 1}',
|
||||
run: (args) => _switchSidebarSection(i),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
@@ -313,11 +266,7 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
}
|
||||
|
||||
static IpcResponse _notActivated() => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'not activated',
|
||||
),
|
||||
);
|
||||
id: '',
|
||||
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,
|
||||
),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
),
|
||||
for (final lineObj in lines)
|
||||
_DiffLineRow(
|
||||
line: (lineObj as Map).cast<String, Object?>(),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -24,16 +24,16 @@ class DiffExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'diff.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Diff',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -70,
|
||||
build: (_) => DiffView(controller: _controller),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'diff.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Diff',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -70,
|
||||
build: (_) => DiffView(controller: _controller),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -413,11 +403,12 @@ class _RulerPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.drawLine(
|
||||
Offset(x, 0),
|
||||
Offset(x, size.height),
|
||||
Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1);
|
||||
Offset(x, 0),
|
||||
Offset(x, size.height),
|
||||
Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -50,14 +50,14 @@ class EditorExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'editor.active',
|
||||
slot: Slots.workspace,
|
||||
title: 'Editor',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 80, // between Claude (90) and welcome (-100)
|
||||
build: (_) => const EditorView(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'editor.active',
|
||||
slot: Slots.workspace,
|
||||
title: 'Editor',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 80, // between Claude (90) and welcome (-100)
|
||||
build: (_) => const EditorView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,18 +36,23 @@ class SyntaxTextController extends TextEditingController {
|
||||
if (source == _highlightedText) return;
|
||||
|
||||
_highlighting = true;
|
||||
_syntax.highlight(path, source).then((result) {
|
||||
_highlighting = false;
|
||||
if (text != source) {
|
||||
_requestHighlight();
|
||||
return;
|
||||
}
|
||||
_highlightedText = source;
|
||||
_spans = result.spans;
|
||||
notifyListeners();
|
||||
}, onError: (_) {
|
||||
_highlighting = false;
|
||||
});
|
||||
_syntax
|
||||
.highlight(path, source)
|
||||
.then(
|
||||
(result) {
|
||||
_highlighting = false;
|
||||
if (text != source) {
|
||||
_requestHighlight();
|
||||
return;
|
||||
}
|
||||
_highlightedText = source;
|
||||
_spans = result.spans;
|
||||
notifyListeners();
|
||||
},
|
||||
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(
|
||||
text: source.substring(spanCharStart, spanCharEnd),
|
||||
style: style?.copyWith(
|
||||
color: TreeSitterService.colorForRole(span.role, tokens),
|
||||
children.add(
|
||||
TextSpan(
|
||||
text: source.substring(spanCharStart, spanCharEnd),
|
||||
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))),
|
||||
enterInsert: true,
|
||||
);
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,15 +19,15 @@ class FilesExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'files.tree',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Files',
|
||||
icon: PhosphorIcons.byName('folder'),
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const FileTreeView(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'files.tree',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Files',
|
||||
icon: PhosphorIcons.byName('folder'),
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const FileTreeView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -16,20 +16,16 @@ class GitExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'git.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Git',
|
||||
icon: PhosphorIcons.byName('git-branch'),
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'git.branch',
|
||||
priority: 10,
|
||||
build: (_) => const GitStatusItem(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'git.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Git',
|
||||
icon: PhosphorIcons.byName('git-branch'),
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,90 +77,58 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
children: [
|
||||
ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
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)),
|
||||
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 []),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_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()))],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [for (final e in c.untracked) e['path'] as String];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
),
|
||||
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 [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_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()),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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) {
|
||||
if (hash != null) commitMsg.clear();
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -26,9 +26,12 @@ class _GraphViewState extends State<GraphView> {
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
});
|
||||
final resp = await kernel.ipc.request(
|
||||
'pql.exec',
|
||||
args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -62,10 +65,7 @@ class _GraphViewState extends State<GraphView> {
|
||||
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 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,
|
||||
@@ -84,10 +84,10 @@ class _GraphNode {
|
||||
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,
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -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())];
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ class ToolStatusItem extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontCaption, color: color),
|
||||
],
|
||||
|
||||
@@ -26,24 +26,21 @@ class KeybindingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
Future<void> deactivate() async => _keymap = null;
|
||||
|
||||
/// Presets that ship today. VS Code / JetBrains (T-64/T-66) join here
|
||||
/// once their YAMLs land.
|
||||
static const _presets = <String, String>{
|
||||
'default': 'Keymap: Default',
|
||||
'vim': 'Keymap: Vim',
|
||||
};
|
||||
/// Presets that ship today, each exposed as a `keymap.preset.<name>`
|
||||
/// command that activates it.
|
||||
static const _presets = <String, String>{'default': 'Keymap: Default', 'vim': 'Keymap: Vim', 'vscode': 'Keymap: VS Code', 'jetbrains': 'Keymap: JetBrains'};
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
for (final entry in _presets.entries)
|
||||
CommandContribution(
|
||||
id: 'keymap.preset.${entry.key}',
|
||||
command: 'keymap.preset.${entry.key}',
|
||||
title: entry.value,
|
||||
run: (_) async {
|
||||
await _keymap?.setPreset(entry.key);
|
||||
return IpcResponse.ok(id: '', data: {'preset': entry.key});
|
||||
},
|
||||
),
|
||||
];
|
||||
for (final entry in _presets.entries)
|
||||
CommandContribution(
|
||||
id: 'keymap.preset.${entry.key}',
|
||||
command: 'keymap.preset.${entry.key}',
|
||||
title: entry.value,
|
||||
run: (_) async {
|
||||
await _keymap?.setPreset(entry.key);
|
||||
return IpcResponse.ok(id: '', data: {'preset': entry.key});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ class MarkdownExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Markdown',
|
||||
icon: PhosphorIcons.byName('file-text'),
|
||||
build: (_) => const MarkdownViewer(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Markdown',
|
||||
icon: PhosphorIcons.byName('file-text'),
|
||||
build: (_) => const MarkdownViewer(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
|
||||
@@ -95,18 +95,12 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_content == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Select a .md file to preview it here.', muted: true),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a .md file to preview it here.', muted: true));
|
||||
}
|
||||
return ClidePaneChrome(
|
||||
title: _path ?? 'viewer',
|
||||
subtitle: '${_content!.split('\n').length} lines',
|
||||
leading: ReaderPinButton(
|
||||
pinned: _nav?.hasPinned ?? false,
|
||||
onTap: _path != null ? _onPin : null,
|
||||
),
|
||||
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _path != null ? _onPin : null),
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
|
||||
@@ -66,7 +66,9 @@ class _Kv extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)),
|
||||
Expanded(child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -89,7 +91,10 @@ class _Licenses extends StatelessWidget {
|
||||
final deps = snap.data!.dependencies;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder), borderRadius: BorderRadius.circular(4)),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -26,67 +26,77 @@ class MenuBarExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'file.openFolder',
|
||||
command: 'file.openFolder',
|
||||
title: 'File: Open Folder…',
|
||||
run: (_) async {
|
||||
await _file.openFolder();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'file.newWindow',
|
||||
command: 'file.newWindow',
|
||||
title: 'File: New Window',
|
||||
run: (_) async {
|
||||
_file.newWindow();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'file.closeWorkspace',
|
||||
command: 'file.closeWorkspace',
|
||||
title: 'File: Close Project',
|
||||
run: (_) async {
|
||||
_file.closeWorkspace();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'help.about',
|
||||
command: 'help.about',
|
||||
title: 'Help: About clide',
|
||||
run: (_) async {
|
||||
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
];
|
||||
CommandContribution(
|
||||
id: 'file.openFolder',
|
||||
command: 'file.openFolder',
|
||||
title: 'File: Open Folder…',
|
||||
run: (_) async {
|
||||
await _file.openFolder();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'file.newWindow',
|
||||
command: 'file.newWindow',
|
||||
title: 'File: New Window',
|
||||
run: (_) async {
|
||||
_file.newWindow();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'file.closeWorkspace',
|
||||
command: 'file.closeWorkspace',
|
||||
title: 'File: Close Project',
|
||||
run: (_) async {
|
||||
_file.closeWorkspace();
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'help.about',
|
||||
command: 'help.about',
|
||||
title: 'Help: About clide',
|
||||
run: (_) async {
|
||||
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The curated File / View / Help tree (T-48). View ends with a `view.*`
|
||||
/// auto-fill so newly-registered view commands surface without edits here.
|
||||
List<TopMenu> buildClideMenuTree() => [
|
||||
TopMenu(title: 'File', mnemonic: 0, nodes: [
|
||||
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
|
||||
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
|
||||
const MenuSeparator(),
|
||||
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
|
||||
]),
|
||||
TopMenu(title: 'View', mnemonic: 0, nodes: const [
|
||||
MenuCommandItem('view.zoomIn'),
|
||||
MenuCommandItem('view.zoomOut'),
|
||||
MenuCommandItem('view.zoomReset'),
|
||||
MenuSeparator(),
|
||||
MenuCommandItem('sidebar.collapse'),
|
||||
MenuCommandItem('context.collapse'),
|
||||
MenuCommandItem('dock.toggle'),
|
||||
MenuCommandItem('panel.focusMode'),
|
||||
MenuSeparator(),
|
||||
MenuAutoFill('view.'),
|
||||
]),
|
||||
TopMenu(title: 'Help', mnemonic: 0, nodes: const [
|
||||
MenuCommandItem('help.about', fallbackTitle: 'About clide'),
|
||||
]),
|
||||
];
|
||||
TopMenu(
|
||||
title: 'File',
|
||||
mnemonic: 0,
|
||||
nodes: [
|
||||
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
|
||||
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
|
||||
const MenuSeparator(),
|
||||
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
|
||||
],
|
||||
),
|
||||
TopMenu(
|
||||
title: 'View',
|
||||
mnemonic: 0,
|
||||
nodes: const [
|
||||
MenuCommandItem('view.zoomIn'),
|
||||
MenuCommandItem('view.zoomOut'),
|
||||
MenuCommandItem('view.zoomReset'),
|
||||
MenuSeparator(),
|
||||
MenuCommandItem('sidebar.collapse'),
|
||||
MenuCommandItem('context.collapse'),
|
||||
MenuCommandItem('dock.toggle'),
|
||||
MenuCommandItem('panel.focusMode'),
|
||||
MenuSeparator(),
|
||||
MenuAutoFill('view.'),
|
||||
],
|
||||
),
|
||||
TopMenu(
|
||||
title: 'Help',
|
||||
mnemonic: 0,
|
||||
nodes: const [MenuCommandItem('help.about', fallbackTitle: 'About clide')],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -143,10 +143,7 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: 12),
|
||||
],
|
||||
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: 12)],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -188,17 +185,11 @@ class NotARepoDialog extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: 13),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: 13,
|
||||
),
|
||||
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: 13),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||
],
|
||||
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -72,18 +72,11 @@ class MenuBar extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
|
||||
builder: (ctx, _) {
|
||||
final menus = resolveMenus(
|
||||
buildClideMenuTree(),
|
||||
kernel.commands,
|
||||
kernel,
|
||||
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
|
||||
);
|
||||
final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id));
|
||||
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel),
|
||||
],
|
||||
children: [for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel)],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -189,11 +182,7 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
|
||||
color: open || hovered ? tokens.listItemHoverBackground : null,
|
||||
child: ClideText(
|
||||
widget.menu.title,
|
||||
fontSize: 12,
|
||||
color: open || hovered ? tokens.globalForeground : tokens.chromeForeground,
|
||||
),
|
||||
child: ClideText(widget.menu.title, fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -108,12 +108,7 @@ String? keymapBindingLabel(KeymapService keymap, String commandId) {
|
||||
/// menus. [bindingLabel] supplies the keybinding string for a command id
|
||||
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
|
||||
/// the command's own `defaultBinding` is used as a fallback.
|
||||
List<ResolvedMenu> resolveMenus(
|
||||
List<TopMenu> tree,
|
||||
CommandRegistry registry,
|
||||
KernelServices services, {
|
||||
String? Function(String commandId)? bindingLabel,
|
||||
}) {
|
||||
List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
|
||||
final placed = <String>{
|
||||
for (final m in tree)
|
||||
for (final n in m.nodes)
|
||||
@@ -140,12 +135,10 @@ List<ResolvedMenu> resolveMenus(
|
||||
}
|
||||
|
||||
List<ResolvedNode> expand(MenuNode n) => switch (n) {
|
||||
MenuCommandItem() => [resolveItem(n)],
|
||||
MenuSeparator() => const [ResolvedSeparator()],
|
||||
MenuAutoFill(:final prefix) => [
|
||||
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
|
||||
],
|
||||
};
|
||||
MenuCommandItem() => [resolveItem(n)],
|
||||
MenuSeparator() => const [ResolvedSeparator()],
|
||||
MenuAutoFill(:final prefix) => [for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command))],
|
||||
};
|
||||
|
||||
return [
|
||||
for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]),
|
||||
|
||||
@@ -60,8 +60,8 @@ class _DockStatusItemState extends State<DockStatusItem> {
|
||||
final (String badge, Color color) = errors > 0
|
||||
? ('✕ $errors', tokens.statusError)
|
||||
: warns > 0
|
||||
? ('⚠ $warns', tokens.statusWarning)
|
||||
: ('✓', tokens.statusSuccess);
|
||||
? ('⚠ $warns', tokens.statusWarning)
|
||||
: ('✓', tokens.statusSuccess);
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'toggle output dock',
|
||||
|
||||
@@ -30,34 +30,34 @@ class OutputExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'output.panel',
|
||||
slot: Slots.dock,
|
||||
title: 'Output',
|
||||
priority: -100, // sort before Problems in the dock tab bar
|
||||
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'output.dock-toggle',
|
||||
priority: 100, // right group, replacing the old app-status item
|
||||
build: (_) => const DockStatusItem(),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'dock.toggle',
|
||||
command: 'dock.toggle',
|
||||
title: 'Toggle output dock',
|
||||
defaultBinding: 'ctrl+j',
|
||||
run: (_) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final a = ctx.arrangement;
|
||||
final opening = !a.isVisible(Slots.dock);
|
||||
a.setVisible(Slots.dock, opening);
|
||||
if (opening && ctx.panels.activeTabIn(Slots.dock) == null) {
|
||||
ctx.panels.activateTab(Slots.dock, 'output.panel');
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {'dock': opening});
|
||||
},
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'output.panel',
|
||||
slot: Slots.dock,
|
||||
title: 'Output',
|
||||
priority: -100, // sort before Problems in the dock tab bar
|
||||
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'output.dock-toggle',
|
||||
priority: 100, // right group, replacing the old app-status item
|
||||
build: (_) => const DockStatusItem(),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'dock.toggle',
|
||||
command: 'dock.toggle',
|
||||
title: 'Toggle output dock',
|
||||
defaultBinding: 'ctrl+j',
|
||||
run: (_) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final a = ctx.arrangement;
|
||||
final opening = !a.isVisible(Slots.dock);
|
||||
a.setVisible(Slots.dock, opening);
|
||||
if (opening && ctx.panels.activeTabIn(Slots.dock) == null) {
|
||||
ctx.panels.activateTab(Slots.dock, 'output.panel');
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {'dock': opening});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,10 +84,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
child: rows.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.',
|
||||
muted: true,
|
||||
),
|
||||
child: ClideText(widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', muted: true),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
@@ -100,12 +97,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
children: [for (final r in rows) _LogRow(record: r)],
|
||||
),
|
||||
),
|
||||
if (!_following)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
child: _JumpPill(onTap: _jumpToLatest),
|
||||
),
|
||||
if (!_following) Positioned(right: 12, bottom: 8, child: _JumpPill(onTap: _jumpToLatest)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -128,15 +120,9 @@ class _OutputViewState extends State<OutputView> {
|
||||
child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Chip(
|
||||
label: 'Level: ${_c.minLevel.name}',
|
||||
onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]),
|
||||
),
|
||||
_Chip(label: 'Level: ${_c.minLevel.name}', onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length])),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(
|
||||
label: 'Source: ${_c.source ?? 'all'}',
|
||||
onTap: _cycleSource,
|
||||
),
|
||||
_Chip(label: 'Source: ${_c.source ?? 'all'}', onTap: _cycleSource),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(label: 'Clear', onTap: _c.clear),
|
||||
],
|
||||
@@ -235,8 +221,14 @@ class _LogRow extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: ClideText(record.source,
|
||||
fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
child: ClideText(
|
||||
record.source,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -248,9 +240,9 @@ class _LogRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
Color _levelColor(LogLevel level, SurfaceTokens tokens) => switch (level) {
|
||||
LogLevel.error => tokens.statusError,
|
||||
LogLevel.warn => tokens.statusWarning,
|
||||
LogLevel.info => tokens.globalForeground,
|
||||
LogLevel.debug || LogLevel.trace => tokens.globalTextMuted,
|
||||
};
|
||||
LogLevel.error => tokens.statusError,
|
||||
LogLevel.warn => tokens.statusWarning,
|
||||
LogLevel.info => tokens.globalForeground,
|
||||
LogLevel.debug || LogLevel.trace => tokens.globalTextMuted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,13 +42,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (c.activePath == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'Open a file to see its links.',
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
|
||||
}
|
||||
return Semantics(
|
||||
label: 'backlinks for ${c.activePath}',
|
||||
@@ -62,35 +56,16 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.activePath!.split('/').last,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
child: ClideText(c.activePath!.split('/').last, color: tokens.globalForeground),
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
),
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
|
||||
),
|
||||
if (c.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Backlinks',
|
||||
links: c.backlinks,
|
||||
pathKey: 'source',
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Outlinks',
|
||||
links: c.outlinks,
|
||||
pathKey: 'target',
|
||||
),
|
||||
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
|
||||
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -101,11 +76,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
}
|
||||
|
||||
class _LinkGroup extends StatelessWidget {
|
||||
const _LinkGroup({
|
||||
required this.label,
|
||||
required this.links,
|
||||
required this.pathKey,
|
||||
});
|
||||
const _LinkGroup({required this.label, required this.links, required this.pathKey});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> links;
|
||||
@@ -119,11 +90,7 @@ class _LinkGroup extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$label (${links.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
if (links.isEmpty)
|
||||
const Padding(
|
||||
|
||||
@@ -17,13 +17,13 @@ class PqlExtension extends ClideExtension {
|
||||
// tab (T-201); this extension keeps only the Backlinks context panel.
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'pql.backlinks',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Links',
|
||||
icon: PhosphorIcons.byName('link'),
|
||||
priority: -80,
|
||||
build: (_) => const BacklinksView(),
|
||||
),
|
||||
];
|
||||
TabContribution(
|
||||
id: 'pql.backlinks',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Links',
|
||||
icon: PhosphorIcons.byName('link'),
|
||||
priority: -80,
|
||||
build: (_) => const BacklinksView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user