Compare commits
@@ -99,6 +99,7 @@ Cutting a release is its own commit. In a single commit:
|
||||
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
|
||||
4. Run `make gen-build-info` so `assets/licenses.yaml` `self.version:` re-syncs from pubspec (it's auto-rewritten by every build but commit the fresh state). Stage the resulting diff alongside step 3.
|
||||
5. Commit subject: `release vX.Y.Z`.
|
||||
6. **Tag it.** After the commit lands, run `make release` — it verifies the version/changelog/clean-tree invariants, runs the full gate, and creates the annotated `vX.Y.Z` tag. Then `git push origin main --follow-tags` to publish the commit and the tag together. **Every released version must have a matching `git tag`** — a CHANGELOG heading without a tag is an incomplete release (T-393: tagging had silently lapsed from v2.2.0 through v2.8.0). Don't hand-roll the tag; `make release` keeps the tag, gate, and version in lockstep. If you tag by hand, use an annotated tag (`git tag -a vX.Y.Z -m "clide vX.Y.Z"`); never push a release commit without its tag.
|
||||
|
||||
`pubspec.yaml` is the single source of truth for the version. Every `make` build/run/test target regenerates `lib/src/build_info.g.dart` (gitignored) and rewrites `assets/licenses.yaml` `self.version:` from it — so the Flutter app sees the current version everywhere without manual sync. Bumping `pubspec.yaml` and the changelog out of sync is the mistake this rule prevents.
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
# 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.
|
||||
# Fast path (T-348, widened T-393): run the full ~2min test suite when the push
|
||||
# touches lib/ (app + runtime Dart source), pubspec.* (deps / version), or the
|
||||
# things that can themselves break the suite or this gate — test/, ci/, and
|
||||
# .githooks/. (The old regex matched only lib/ and pubspec.*, so a push that
|
||||
# ONLY changed a test, a ci/ gate script, or this hook skipped the whole suite.)
|
||||
# Pure assets/docs changes still ride along with the next lib-touching push.
|
||||
# There is no release CI — the full suite is only ever run here or via
|
||||
# `make push-check`. So a push touching none of the above 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)"
|
||||
@@ -40,16 +42,22 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do
|
||||
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.
|
||||
# Paths that force the full gate: source (lib/), deps/version (pubspec.*), and
|
||||
# the dirs that can themselves break the suite or this gate (test/, ci/,
|
||||
# .githooks/). Single source of truth — test/tooling/pre_push_hook_test.dart
|
||||
# reads this exact pattern, so narrowing it fails that test (the T-393 guard).
|
||||
trigger_re='^(lib/|test/|ci/|\.githooks/|pubspec\.)'
|
||||
|
||||
# Run the full gate when a trigger path 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)"
|
||||
trigger_files="$(printf '%s\n' "$changed" | grep -E "$trigger_re" || 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"
|
||||
echo "==> pre-push: no source/test/ci/hook/pubspec change — decisions + changelog gates, skipping tests"
|
||||
make decisions-validate changelog-gate
|
||||
else
|
||||
echo "==> pre-push: make push-check"
|
||||
|
||||
@@ -35,3 +35,24 @@ 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBDSM0PRGYR61R0NWYAT9VDC', '06FFW49VYMPF18PYQXD9PCMHN8', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'a5146a3cfb2f6a3fd904224150836336', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W3V175EM4F8ZHC6JFM4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'd57042b235b73ed2d25252d5253e6e4a', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W6GP535GR8XD1XEHYWG', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '07fe1945cb3e60c43cce0989366af8fb', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49W95HJK08BCRQSWFZBF4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'e4484230e2202044baaa5ce7a2438bbb', 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 ('06FFW49W3V175EM4F8ZHC6JFM4', '06FFW49W95HJK08BCRQSWFZBF4', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '24cc5602737e75d30377522f38b3e7b5', 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 ('06FFW49W95HJK08BCRQSWFZBF4', '06FFW49WBTT3ESK6QG0XZ1VN2G', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, '51f4759153b30d2a62ed6c69ab62b84d', 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 ('06FFW49VYMPF18PYQXD9PCMHN8', '06FFW49WEE5N4PESF5G1G5JRHR', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'b348cfc357b73fb28366459795752d8b', 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 ('06FFW49W95HJK08BCRQSWFZBF4', '06FFW49WEE5N4PESF5G1G5JRHR', '2026-06-25 10:24:38', '2026-06-25 10:24:38', NULL, 'e114eff56fef8c92fe3b19df2b8b6e3c', 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 ('06FGSQ789MDB00EQAXTXBVJDXM', '06FGSQ8B7WDKZ174GJDGWW0GS0', '2026-06-28 06:14:17.763', '2026-06-28 06:14:17.763', NULL, '973169c6f426d9b4bcfd925ce0f36f32', 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 ('06FGSQ8B7WDKZ174GJDGWW0GS0', '06FGSQ8YSZA5YNP1JE8SJHXWBG', '2026-06-28 06:14:17.799', '2026-06-28 06:14:17.799', NULL, '60769ef20f951c01c5d20f512913b946', 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 ('06FB2AD3HPR3HXSVASVZEX8PK0', '06FB2ACSDBDZARV3NNGYD9NYYR', '2026-06-28 13:28:39.768', '2026-06-28 13:28:39.768', NULL, 'd5a7084fa717846475fad88d8f30febd', 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 ('06FB234WP4Y6Q16A0HFW8BSXMG', '06FB2ACSDBDZARV3NNGYD9NYYR', '2026-06-28 13:28:39.808', '2026-06-28 13:28:39.808', NULL, '79027c30d19f6f7d43c3068fd80ba8ed', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 14:01:13.773', '2026-06-28 14:01:13.773', NULL, '68cfce9178bbb310b87c76a908781c76', 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', '06FB2ETJQP0CT6X7W3CWZ6NS9G', '2026-06-10 11:12:20', '2026-06-28 15:29:40.913', '2026-06-28 15:29:40.913', '16d8a936aa3083cd16ca25a57a2e1b86', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', '06FB2ERREMEEF26KKHGNZBWW64', '2026-06-28 15:29:44.904', '2026-06-28 15:29:44.904', NULL, '545d2ce4f863fd5bb2eee240b696be0c', 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', '06FB234WP4Y6Q16A0HFW8BSXMG', '2026-06-10 11:12:11', '2026-06-28 15:29:57.491', NULL, '6e43179e6b211be30766ca56f5ed5885', 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', '06FB2AD3HPR3HXSVASVZEX8PK0', '2026-06-28 15:30:00.399', '2026-06-28 15:30:00.399', NULL, 'e0961be9550747530f530a7fd300583f', 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', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 15:30:03.803', '2026-06-28 15:30:03.803', NULL, 'fa47a4a4aff706215efa3a449681d18f', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-28 15:30:10.567', '2026-06-28 15:30:10.567', NULL, '52cd02f3a176cd078db479ef850cbcf4', 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 ('06FGYPJ6FTEPP4JK3D7D01ZSMM', '06FGX26B0NAC9WRJMVB0QE6CV8', '2026-06-28 17:50:13.767', '2026-06-28 17:50:13.767', NULL, '3e0ac1d814ac13a12b68714c7320075f', 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 ('06FB2G2KHKT5CJYR0TK1WQGMD0', '06FB2EV29HSK6EJ5VF50R87VC4', '2026-06-28 15:30:10.567', '2026-06-29 21:58:13.907', '2026-06-29 21:58:13.907', '3b7ec3277f971c3646a7587b06606335', 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);
|
||||
|
||||
@@ -302,3 +302,40 @@ 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDEMY3DQWDNY535PMMSB0CSW', 'T-475', '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, 'bb2b56ea9a76d967123238f588c70aba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'T-476', '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, 'f75a0a8c00b0ec40c110c1169e42fe2c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FEWB9JA5CMTXB5D7G1NSHAV4', 'T-477', '2026-06-22 07:13:19.958', '2026-06-22 07:13:19.958', NULL, '0314d96a690bdadc122ce0a1e9ed0f5c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFKFYBZFKBX0WV574QJEH2BC', 'T-478', '2026-06-24 13:09:16.923', '2026-06-24 13:09:16.923', NULL, 'f1ea48c2cfa08915841d99eea06a9974', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'T-477', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '64605163f24e3846463f3afb4ed62fd1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'T-478', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '1c2e59e6220c0b288103c4a71a8282ca', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W6GP535GR8XD1XEHYWG', 'T-479', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '2a6162f26c4c0b6e7bceb1f1a2ffe167', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W95HJK08BCRQSWFZBF4', 'T-480', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '8a42c8c1fc2036d6ea73521266e87235', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'T-481', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, 'd1d616d020e6ea8661c1f5553e3001eb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'T-482', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '557f92392db9b5c1e6b6840fe1293b7d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'T-483', '2026-06-25 09:16:42', '2026-06-26 21:35:04.076', NULL, '2841b381bbb03e6a9cdb970280cd2207', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'T-484', '2026-06-25 09:16:42', '2026-06-26 21:35:07.645', NULL, 'd90df1d5e2da44e81f60edb8c5fb2823', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGN13H42TP2MP3T27CTSCDRW', 'T-485', '2026-06-27 19:17:59.713', '2026-06-27 19:17:59.713', NULL, 'b345ba60d55cfdbc7c2e9bb9187ee171', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'T-486', '2026-06-27 22:43:31.629', '2026-06-27 22:43:31.629', NULL, 'cd344c35e0eca5673be26790aa8d292c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ789MDB00EQAXTXBVJDXM', 'T-487', '2026-06-28 06:13:51.821', '2026-06-28 06:13:51.821', NULL, '184f63035c0a014dc4fb863e512f8779', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'T-488', '2026-06-28 06:14:00.768', '2026-06-28 06:14:00.768', NULL, '69e3283ee3c10bcd2ca5b5cbad6a8b1f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'T-489', '2026-06-28 06:14:05.776', '2026-06-28 06:14:05.776', NULL, '0b37f24529eb3bb4f2b295dda3bb04ea', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQP1YPG70RPQJ4XZDH2T1M', 'T-490', '2026-06-28 06:15:53.077', '2026-06-28 06:15:53.077', NULL, '586b7d8495ad019b436fddbf973c9e63', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVCY4M0ZK72HZT2FSZF968W', 'T-491', '2026-06-28 10:08:32.933', '2026-06-28 10:08:32.933', NULL, '3895f296d80984ccd613ca9853a05eda', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVDBD0F14FV5CDJQB063YSR', 'T-492', '2026-06-28 10:10:21.572', '2026-06-28 10:10:21.572', NULL, '341150811f14eb3f4486067ffb1197c4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGVNCZS0JZP5Y7596GBV4RFC', 'T-493', '2026-06-28 10:45:31.721', '2026-06-28 10:45:31.721', NULL, 'eec40f4714242cf343035481684f2aba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGX26B0NAC9WRJMVB0QE6CV8', 'T-494', '2026-06-28 14:01:13.733', '2026-06-28 14:01:13.733', NULL, '37bf4baec69cb3d06072793cd3d60a46', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'T-495', '2026-06-28 17:50:02.366', '2026-06-28 17:50:02.366', NULL, '40dbd25d323a8192e8f65a6e1c2dd33d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'T-496', '2026-06-28 18:00:41.420', '2026-06-28 18:00:41.420', NULL, 'd2e472745c710eb00555b2b824d4e214', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZK6PWBRBK9J2XPJ55Y3630', 'T-497', '2026-06-28 19:55:10.439', '2026-06-28 19:55:10.439', NULL, '56f8bf5d0e9a52d9ce3c7a293b5f8972', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPT1TMFMR0KYFGR087W', 'T-498', '2026-06-28 19:56:14.646', '2026-06-28 19:56:14.646', NULL, 'c62ee89f4aa203262fab74da8604777a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPSSHEG6ZBM0B4DMM6C', 'T-498', '2026-06-28 19:56:14.655', '2026-06-28 19:56:14.655', NULL, '1a47f2a97f13a7565080893758424fec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPW9GMXNACA38MK56HR', 'T-498', '2026-06-28 19:56:14.655', '2026-06-28 19:56:14.655', NULL, '2c606da2b11071cee3bfcee4855d4e66', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW1721NKWY6XZG3KYB8', 'T-499', '2026-06-28 19:56:47.968', '2026-06-28 19:56:47.968', NULL, '135e7af3ddf32a676fcfadd150061dd6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW6EDH8TR45M86EWB5M', 'T-499', '2026-06-28 19:56:47.977', '2026-06-28 19:56:47.977', NULL, 'fc370ccee9cd94d0de9988f08963b8f1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-499', '2026-06-28 19:56:47.978', '2026-06-28 19:56:47.978', NULL, '99eb5554507cf5b29a158838f156d544', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPW9GMXNACA38MK56HR', 'T-500', '2026-06-28 19:56:14.655', '2026-06-28 19:57:02.538', NULL, '6b529f95abd005b73e20b293dcba765c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKEHPT1TMFMR0KYFGR087W', 'T-501', '2026-06-28 19:56:14.646', '2026-06-28 19:57:25.895', NULL, '4e1059be29e69c094168bb55e06704a4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW1721NKWY6XZG3KYB8', 'T-502', '2026-06-28 19:56:47.968', '2026-06-28 19:57:25.904', NULL, '2c08cf1dfc5c7ba16f756db7c80bc300', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW6EDH8TR45M86EWB5M', 'T-503', '2026-06-28 19:56:47.977', '2026-06-28 19:57:25.905', NULL, '5177977a9215f17efa101e50b03ad8eb', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-504', '2026-06-28 19:56:47.978', '2026-06-28 19:57:25.906', NULL, 'cb7f70a279326ff6e29a8c7bdeb78c0f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGZKJKW9P697RTQ68R3NKBR8', 'T-504', '2026-06-28 19:56:47.978', '2026-06-28 19:57:25.906', NULL, 'cb7f70a279326ff6e29a8c7bdeb78c0f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FH621XBYJBGE1XPSFFK4YZGM', 'T-505', '2026-06-29 10:58:54.943', '2026-06-29 10:58:54.943', NULL, 'b402cc72b921d446a6e4f6c759d72d54', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FHAX7FV5KWGZQ31617R63W94', 'T-506', '2026-06-29 22:16:52.953', '2026-06-29 22:16:52.953', NULL, 'fdeccc75d6a70fae57de814d11695473', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
# AGENTS.md for clide — Mistral Vibe operating as Claude Code peer
|
||||
|
||||
This file configures Mistral Vibe to operate in this repo with the same
|
||||
effectiveness as Claude Code. It distills the CLAUDE.md guardrails and all
|
||||
`.claude/skills/` into Vibe's instruction hierarchy, preserving "Claude punch"
|
||||
in a hybrid workflow.
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
You are operating in **clide** — a Flutter/Dart IDE for Claude Code CLI.
|
||||
Your role: **peer to Claude Code**, not replacement. Maintain Claude's
|
||||
behavioral standards, tool discipline, and architectural rigor.
|
||||
|
||||
**Primary directive:** Never lose "Claude punch" — the combination of
|
||||
strict guardrails, CLI-first interaction, and parity between UI and CLI
|
||||
that defines effective operation in this repo.
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Guardrails (from CLAUDE.md)
|
||||
|
||||
These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
|
||||
- **Flutter desktop is the host. No Electron, ever.**
|
||||
- **Single process.** The Flutter app hosts everything in-process: IPC server,
|
||||
subsystem handlers (pane, files, editor, git, pql), extensions.
|
||||
- **CLI-first, not MCP.** Drive via `clide <subsystem> <verb>` Bash commands.
|
||||
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI
|
||||
(`posix_openpt` + `posix_spawn`). `pql` (Go) handles vault queries.
|
||||
- **Own the rendering stack.** PTY, markdown, graph, canvas — all clide-owned.
|
||||
- **User/Claude parity (D-6).** Every CLI subcommand has a UI affordance,
|
||||
and every UI action has a CLI equivalent.
|
||||
- **pql: wrap, don't duplicate.** Clide wraps pql; never re-implements it.
|
||||
- **Repo-is-the-workspace.** Git repo root is the workspace.
|
||||
- **Decision discipline.** All architectural choices live in
|
||||
`governance/decisions/<domain>.md` as `D-NNN` records. Open questions as
|
||||
`Q-NNN` under `governance/questions/<domain>.md`. Rejected as `R-NNN`.
|
||||
- **No pre-existing excuse.** Solo-dev repo — if `make test` is red, fix it
|
||||
first, then your work. Surface blockers; don't push on top of broken state.
|
||||
|
||||
---
|
||||
|
||||
## Tool Discipline (from CLAUDE.md)
|
||||
|
||||
### Make targets are the entry points
|
||||
|
||||
| Purpose | Command | Never call directly |
|
||||
|---------|---------|---------------------|
|
||||
| Launch app | `make run` | `flutter run` |
|
||||
| Static analysis | `make analyze` | `flutter analyze` |
|
||||
| Format | `make format` | `dart format` |
|
||||
| Fast test suite | `make test` | `flutter test` |
|
||||
| Core subsystem tests | `make test-core` | underlying scripts |
|
||||
| Accessibility tests | `make test-a11y` | underlying scripts |
|
||||
| Integration tests | `make test-integration` | underlying scripts |
|
||||
| Pre-push gate | `make push-check` | `ci/*` scripts |
|
||||
| Setup hooks | `make hooks` | `cp .githooks/* .git/hooks/` |
|
||||
| Clean | `make clean` | `rm -rf build/` |
|
||||
|
||||
### Shell hygiene
|
||||
|
||||
- **Working directory is repo root** — never `cd /path/to/clide` or `git -C`
|
||||
- **One command per invocation** — no `&&`/`;` chaining
|
||||
- Exception: `git commit -F` HEREDOC for multi-line messages
|
||||
- Prefer Read/Edit/Grep tools over `cat`/`sed`/`grep` for file inspection
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow (from git-commit skill)
|
||||
|
||||
### Commit message format: Conventional Commits 1.0
|
||||
|
||||
```
|
||||
<type>(<scope>): <imperative subject> (<T-NNN>)
|
||||
|
||||
Optional body: explain WHY, not WHAT. Wrap at ~72 chars.
|
||||
|
||||
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
|
||||
```
|
||||
|
||||
**Type:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`
|
||||
- Use `feat`/`fix` for user-visible behavior
|
||||
- Use `chore` for bookkeeping (`chore(plan)` for pql ticket housekeeping)
|
||||
- Append `!` after scope for breaking changes: `feat(ipc)!: ...`
|
||||
|
||||
**Scope:** Optional but preferred — subsystem: `settings`, `vim`, `pty`, `git`, `plan`
|
||||
- Lower-case, no spaces
|
||||
|
||||
**Subject:** ≤ 72 characters INCLUDING prefix. No emojis. No "and".
|
||||
|
||||
**Ticket ref:** Trailing `(T-NNN)` when work has a ticket.
|
||||
|
||||
**Body:** Explain the *why*. The diff shows the *what*. Don't restate it.
|
||||
- Hard cap: **60 words per bullet** for CHANGELOG entries
|
||||
- No multi-paragraph bullets
|
||||
- No sub-headers inside bullets
|
||||
- No probe numbers, latency stats, or %-coverage deltas
|
||||
|
||||
### Logically-separated commits
|
||||
|
||||
1. **Read `git status` + `git diff` first** — never stage blind
|
||||
2. **Group by concern, not location:**
|
||||
- Bookkeeping: `.gitignore`, lockfiles, config
|
||||
- Documentation: `README.md`, ADRs, design notes
|
||||
- Tooling/skills: reusable, non-project-specific
|
||||
- Project conventions: repo's own rules
|
||||
- Feature/subsystem: one cohesive change
|
||||
- Layer changes: app, sidecar CLI, daemon, IPC, pql wrapper, canvas, git panel
|
||||
3. **Prefer many small focused commits over one large mixed one**
|
||||
4. **Use `git add <specific paths>`** — NEVER `git add -A`, `git add .`, `git add -u`
|
||||
5. **Verify between commits** with `git status`, `git diff --staged`, `git log -1`
|
||||
|
||||
### Changelog discipline
|
||||
|
||||
- **Every user-visible commit must touch CHANGELOG.md** under `## [Unreleased]`
|
||||
- Use Keep a Changelog 1.1.0 format with sections: Added, Changed, Deprecated, Removed, Fixed, Security
|
||||
- Entries: short imperative phrases describing user-facing impact
|
||||
- **60 words hard cap per bullet** — verify with `make changelog-gate`
|
||||
- What skips changelog: pure bookkeeping with no user-visible effect
|
||||
|
||||
### Safety rules (reinforced)
|
||||
|
||||
- **Never** `--no-verify`
|
||||
- **Never** `--amend` unless user explicitly asks
|
||||
- **Never** force-push to `main` or `master`
|
||||
- **Always** check `git status` before staging, `git diff --staged` before committing
|
||||
- Commit message via HEREDOC:
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<message>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### What NOT to commit
|
||||
|
||||
- `.env`, `*.env.local`
|
||||
- `.claude/settings.local.json`
|
||||
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/`, `app/.dart_tool/`
|
||||
- SQLite index files: `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`
|
||||
- Coverage/test output: `*.out`, `coverage.*`, `*.test`
|
||||
- `.pql/changelog/` — auto-staged by pre-commit hook from pql DB
|
||||
|
||||
---
|
||||
|
||||
## pql — Vault Queries + Project Planning
|
||||
|
||||
`pql` indexes a vault into SQLite and exposes structural queries plus a
|
||||
planning layer for decision records and tickets. One binary, two surfaces.
|
||||
|
||||
### Precondition
|
||||
|
||||
```bash
|
||||
command -v pql
|
||||
```
|
||||
|
||||
If absent, tell the user to install from
|
||||
https://github.com/postmeridiem/pql/releases/latest. Don't install it
|
||||
yourself. Don't fall back to grep unless the user explicitly asks.
|
||||
|
||||
### First touch: learn the vault
|
||||
|
||||
```bash
|
||||
pql schema
|
||||
```
|
||||
|
||||
Returns one row per frontmatter key with observed types and file counts.
|
||||
Run once per session before writing queries.
|
||||
|
||||
---
|
||||
|
||||
### Surface 1: Vault queries
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql files [glob]` | List indexed files; optional glob filter |
|
||||
| `pql tags [--sort count]` | Distinct tags with counts |
|
||||
| `pql backlinks <path>` | Files linking TO a path |
|
||||
| `pql outlinks <path>` | Links FROM a file |
|
||||
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
|
||||
| `pql schema` | Typed frontmatter schema |
|
||||
| `pql base <name>` | Execute an Obsidian .base file |
|
||||
| `pql shell` | Interactive REPL (indexes once, then query per line) |
|
||||
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
|
||||
| `pql doctor` | Resolved vault/config/DB/index state |
|
||||
|
||||
#### DSL examples
|
||||
|
||||
```sql
|
||||
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
|
||||
SELECT path WHERE 'project' IN tags ORDER BY path
|
||||
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
|
||||
```
|
||||
|
||||
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
|
||||
content into the command line.
|
||||
|
||||
#### Query cookbook
|
||||
|
||||
- **Files in folder** → `pql files 'sessions/*'`
|
||||
- **Top tags** → `pql tags --sort count --limit 20`
|
||||
- **What links to X?** → `pql backlinks members/vaasa/persona.md`
|
||||
- **Date range** → `pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
|
||||
- **Run a Base** → `pql base council-sessions`
|
||||
- **Inspect one file** → `pql meta members/vaasa/persona.md --pretty`
|
||||
|
||||
---
|
||||
|
||||
### Surface 2: Planning (decisions + tickets)
|
||||
|
||||
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
|
||||
cache). Decision records come from the DQR tree — `governance/{decisions,
|
||||
questions,rejected}/<domain>.md` by default (D-21), configurable via
|
||||
`dqr_dir` in `.pql/config.yaml` or the `PQL_DQR_DIR` env var (env > file >
|
||||
default); a legacy flat `decisions/` is auto-detected as a fallback.
|
||||
Tickets are SQLite-native.
|
||||
|
||||
#### Decision subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql decisions sync [--no-style]` | Parse the DQR tree → upsert into pql.db; surfaces style warnings (filename, subdir-type, domain pairing/conflicts) unless `--no-style` |
|
||||
| `pql decisions validate [--no-style]` | Dry-run parse; structural errors exit non-zero, style issues warn (suppress with `--no-style`) |
|
||||
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
|
||||
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
|
||||
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
|
||||
| `pql decisions coverage` | Confirmed decisions without tickets |
|
||||
| `pql decisions refs <id>` | Cross-references involving a decision |
|
||||
|
||||
Always `pql decisions sync` before querying if decisions/*.md may have changed.
|
||||
|
||||
#### Ticket subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql ticket new <type> "title" [--parent T-NNN] [--decision D-NNN] [--priority P] [--id-only]` | Create (emits T-NNN; `--parent` files it under an epic/story in one step; `--id-only` prints the bare id for tree-creation scripts) |
|
||||
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L] [--under T-NNN] [--leaf] [--unblocked]` | List with filters. `--under` = recursive descendants of a ticket; `--leaf` = no children; `--unblocked` = blockers all reached a terminal status |
|
||||
| `pql ticket show <id[,id,...]> [--with-context] [--with-blockers] [--with-children] [--tree] [--depth N]` | Show one or more (comma-batch → array of show-trees). `--with-children` = direct children; `--tree` = nested descendant subtree + direct parent (cap with `--depth N`) |
|
||||
| `pql ticket status <id> <new-status> [--force]` | Change status. Closing (terminal status) is blocked while the ticket has open children; `--force` cascades that status to all not-yet-closed descendants and lists them |
|
||||
| `pql ticket statuslist` | List the configured status vocabulary (name, label, class, order, is_default, is_terminal) — what a UI reads to render columns |
|
||||
| `pql ticket relabel <id\|record_id> [--new-label T-NNN] [--fix-prose]` | Reassign a ticket's friendly T-NNN label (reconcile a duplicate-label collision). Identity (record_id) and the structural graph are untouched; only the label moves. `--fix-prose` rewrites stale T-NNN mentions in DQR markdown |
|
||||
| `pql ticket assign <id> <agent>` | Set assignee |
|
||||
| `pql ticket setparent <id[,id,...]> <parent-id \| none>` | Set (or clear with `none`) a ticket's **parent** — the hierarchy link (epic→story→task). Positional, not a flag. This is the parent/child relationship, distinct from blockers |
|
||||
| `pql ticket append <id> <text\|--file\|--stdin>` | Append to the description (blank-line separated); never round-trips existing text |
|
||||
| `pql ticket block <id> --by <other>` | Add a **blocker** (a dependency: <id> can't start until <other> is done) — NOT a parent/child link; use `setparent` or `new --parent` for hierarchy |
|
||||
| `pql ticket unblock <id> --from <other>` | Remove blocker |
|
||||
| `pql ticket team <id> <team>` | Set team |
|
||||
| `pql ticket label <id> add\|rm <label>` | Manage labels |
|
||||
| `pql ticket board [--team T]` | Kanban board view |
|
||||
| `pql ticket refine list` | Tickets with empty descriptions, status-priority-sorted |
|
||||
| `pql ticket refine next [--skip N]` | Head of the unrefined queue with full show-tree + remaining count |
|
||||
| `pql ticket refine write <id> <json\|--file\|--stdin>` | Patch writable fields (title, description, priority, type) |
|
||||
|
||||
Ticket types: initiative, epic, story, task, bug.
|
||||
The `id` you type and see (T-NNN) is a friendly label backed by a stable
|
||||
underwater `record_id` (also in output); two clones never collide on identity,
|
||||
and a duplicate label is fixed with `pql ticket relabel` (D-26).
|
||||
Statuses are a per-vault vocabulary (`ticket_statuses` in `.pql/config.yaml`),
|
||||
defaulting to: backlog, ready, in_progress, review, done, cancelled. Each status
|
||||
has a class — initial, active, review, terminal — that the engine reasons about.
|
||||
Run `pql ticket statuslist` to discover the live set. Any status can transition
|
||||
to any other — pql does not enforce a state machine — except that a ticket
|
||||
cannot reach a terminal status while it has open children (use `--force` to
|
||||
cascade the close down the subtree).
|
||||
|
||||
#### Plan subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
|
||||
| `pql plan whatsnext` | Next ticket to work on (active work, then the "ready" lane) with full context bundle |
|
||||
| `pql plan review` | Next ticket awaiting review with full context bundle |
|
||||
| `pql plan export [--stage]` | Append changed planning rows to `.pql/changelog/<table>/<YYYY-MM>.sql` (the git-tracked log of record); `--stage` also `git add`s them. Normally a no-op — mutations already write through |
|
||||
| `pql plan import [--legacy FILE]` | Replay `.pql/changelog/` into `pql.db` (or one-time `--legacy pql-plan.json` migration from the pre-D-15 snapshot) |
|
||||
| `pql plan rebuild` | Drop replicated tables and replay `.pql/changelog/` from scratch. Warns on stderr (`changelog.ticket_id_collision`) + lists `collisions` in the result if one ticket id was filed twice across clones |
|
||||
|
||||
#### Versioning planning state
|
||||
|
||||
`pql.db` is gitignored — the durable, git-tracked artifact is
|
||||
`.pql/changelog/` (D-15/D-16). Ticket mutations **write through** to the
|
||||
changelog synchronously, so it is always current; you never have to
|
||||
remember to "export". The hooks installed by `pql init` do the rest:
|
||||
|
||||
- `pre-commit` stages `.pql/changelog/` so it lands in the same commit as
|
||||
the change that produced it.
|
||||
- `post-merge` replays incoming changelog edits (`pql plan import`) and
|
||||
re-syncs decisions from their markdown.
|
||||
- `post-checkout` / `post-rewrite` rebuild `pql.db` from the changelog.
|
||||
|
||||
On a fresh clone, `pql plan import` (run automatically on first open)
|
||||
replays the changelog into a new `pql.db`. There is **no** `pql-plan.json`
|
||||
snapshot — that artifact is retired; `pql plan export` is now only a
|
||||
manual catch-up/reconcile.
|
||||
|
||||
A ticket mutation (create / status transition / any write) leaves
|
||||
`.pql/changelog/` dirty by design — the `pre-commit` hook stages it onto
|
||||
the next `git commit`. This is expected, not a problem to flag. Don't
|
||||
narrate "the ticket won't persist until committed" on every edit; either
|
||||
fold the bookkeeping into a commit or trust the normal commit flow.
|
||||
|
||||
#### Planning cookbook
|
||||
|
||||
- **Sync and list confirmed** → `pql decisions sync && pql decisions list --type confirmed`
|
||||
- **Show with refs** → `pql decisions show D-5 --with-refs --pretty`
|
||||
- **Read full body** → `pql decisions read D-5`
|
||||
- **Create ticket** → `pql ticket new task "implement X" --decision D-5`
|
||||
- **Create ticket, capture id for a script** → `id=$(pql ticket new task "implement X" --id-only)` — prints just `T-NNN`
|
||||
- **File a ticket under an epic** → `pql ticket new bug "fix X" --parent T-276` (one step), or reparent an existing one → `pql ticket setparent T-9 T-276` (clear with `none`). Parent = hierarchy; use `block` only for blocking dependencies
|
||||
- **Batch close** → `pql ticket status T-1,T-2,T-3 done`
|
||||
- **Full context** → `pql ticket show T-5 --with-context --pretty`
|
||||
- **Batch show** → `pql ticket show T-1,T-2,T-3 --pretty`
|
||||
- **Refine next ticket** → `pql ticket refine next --pretty`, then `pql ticket refine write T-N '{"description":"..."}'`
|
||||
- **Append a note** → `pql ticket append T-5 "benchmarked; TTL now 5m"` — blank-line separated, never overwrites; use `--file note.md` or `--stdin` for longer content
|
||||
- **Subtree of an epic** → `pql ticket show T-2 --tree --pretty` — nested `subtree` + direct parent in `ancestors`; add `--depth N` to cap levels
|
||||
- **Ready leaf work under an epic** → `pql ticket list --under T-2 --leaf --unblocked` — leaf tickets beneath T-2 whose blockers have all reached a terminal status; the batch complement to `plan whatsnext`
|
||||
- **What's next?** → `pql plan whatsnext --pretty`
|
||||
- **Review queue** → `pql plan review --pretty`
|
||||
- **Coverage gaps** → `pql decisions coverage`
|
||||
- **Dashboard** → `pql plan status --pretty`
|
||||
- **Force a changelog catch-up** → `pql plan export` (normally a no-op; mutations already write through to `.pql/changelog/`)
|
||||
|
||||
---
|
||||
|
||||
### Output contract (both surfaces)
|
||||
|
||||
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
|
||||
- **stderr:** JSON diagnostics `{"level":"...","code":"pql.<phase>.<kind>","msg":"..."}`.
|
||||
- **Exit codes:**
|
||||
- `0` — success, including zero matches (empty `[]` — say "no matches", not "failed")
|
||||
- `64` — bad flag
|
||||
- `65` — parse/compile error (pass stderr back)
|
||||
- `66` — vault/config not found
|
||||
- `69` — unavailable
|
||||
- `70` — internal error
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
|
||||
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
|
||||
- Don't parse errors — pass stderr diagnostics back directly.
|
||||
- Don't forget `pql decisions sync` before querying decisions.
|
||||
- Don't try to install or upgrade pql — instruct the user if missing.
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- **Body text search** → `grep`/`rg`.
|
||||
- **Reading file contents** → `Read` tool.
|
||||
- **Code structure** → tree-sitter / LSP.
|
||||
- **Modifying vault files** → `Write`/`Edit`. pql doesn't write to vault content.
|
||||
|
||||
---
|
||||
|
||||
## Driving clide UI via CLI (from clide skill)
|
||||
|
||||
**Core principle: Every UI action has a CLI equivalent (D-6).**
|
||||
Discover the live surface; don't hard-code it.
|
||||
|
||||
### Discover capabilities
|
||||
|
||||
```bash
|
||||
clide capabilities
|
||||
```
|
||||
|
||||
Returns JSON: every registered command, split into `subsystem` + `verb`,
|
||||
with argument schema. **This is authoritative** — re-run it, don't trust
|
||||
remembered lists.
|
||||
|
||||
### Slots (layout areas)
|
||||
|
||||
- `sidebar` — left
|
||||
- `workspace` — center (where Claude lives)
|
||||
- `context` — right
|
||||
- `statusbar` — bottom
|
||||
|
||||
### Observe state
|
||||
|
||||
```bash
|
||||
# One-shot orientation
|
||||
clide status
|
||||
|
||||
# Narrower snapshots
|
||||
clide pane list
|
||||
clide editor active
|
||||
clide git status
|
||||
```
|
||||
|
||||
### Drive UI
|
||||
|
||||
```bash
|
||||
# Open a doc in a GUI reader
|
||||
clide ui open <reader> <ref>
|
||||
# readers: tickets, decisions, markdown, diff
|
||||
# examples:
|
||||
clide ui open tickets T-123
|
||||
clide ui open decisions D-456
|
||||
clide ui open diff lib/src/foo.dart
|
||||
clide ui open markdown docs/bar.md
|
||||
|
||||
# Show diff and scroll to file
|
||||
clide ui diff lib/src/foo.dart
|
||||
|
||||
# Raise a toast
|
||||
clide ui toast "message" --severity success|warning|error|info
|
||||
|
||||
# General: clide <subsystem> <verb> [args]
|
||||
clide files list
|
||||
clide editor open <path>
|
||||
clide pane focus <pane-id>
|
||||
```
|
||||
|
||||
**Convention:** If a drive verb has no live GUI, return toolError
|
||||
("no live UI to drive"), not hang. Exit code conveys ok/usage/tool error.
|
||||
JSON on stdout.
|
||||
|
||||
### You only see what flows through clide
|
||||
|
||||
Your own non-`clide` shell work (plain file reads, `make test`, `git`) is
|
||||
outside clide's view by design (D-83). Run it **through** `clide ...` if
|
||||
you want clide to observe it.
|
||||
|
||||
---
|
||||
|
||||
## Testmode (from testmode skill)
|
||||
|
||||
`ClideTestApp` is a lightweight Flutter app for integration testing.
|
||||
Catches regressions unit tests cannot: missing binaries, broken subprocess
|
||||
wiring, IPC dispatch failures, extension activation order, theme parse errors.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
make run-testmode # all categories, 60s timeout
|
||||
make run-testmode TESTMODE_CATEGORY=toolchain
|
||||
make run-testmode TESTMODE_CATEGORY=ipc
|
||||
make run-testmode TESTMODE_CATEGORY=extensions
|
||||
make run-testmode TESTMODE_TIMEOUT=120
|
||||
```
|
||||
|
||||
Results: stdout + `/tmp/clide-testmode.log`
|
||||
Last line is machine-readable JSON: `{"passed":N,"failed":M,"total":N+M,"failures":[...]}`
|
||||
|
||||
### When to run
|
||||
|
||||
| Changed area | Category | Why |
|
||||
|-------------|----------|-----|
|
||||
| Toolchain, PATH, ptyc | `toolchain` | Binary resolution + exec |
|
||||
| IPC envelope, dispatcher | `ipc` | Round-trip + error contract |
|
||||
| Extension manifest, activate | `extensions` | Register + activate lifecycle |
|
||||
| Theme YAML, loader | `extensions` | Theme parse is in this category |
|
||||
| Platform config | `all` | Full rebuild validates everything |
|
||||
| Any doubt | `all` | ~30s, cheap insurance |
|
||||
|
||||
### Interpreting output
|
||||
|
||||
- `[testmode] exec | ... | OK` — subprocess ran, exit 0 or 1
|
||||
- `[testmode] PASS | ...` — assertion passed
|
||||
- `[testmode] FAIL | ...` — assertion failed
|
||||
- `[testmode] exec | ... | EXCEPTION` — binary not found or not executable
|
||||
- `[testmode] exec | ... | TIMEOUT` — subprocess hung
|
||||
|
||||
If no `[testmode]` lines appear, the testmode gate didn't fire — verify
|
||||
`CLIDE_TESTMODE` is set to a non-empty string.
|
||||
|
||||
### Adding tests
|
||||
|
||||
All test logic in `lib/test_app.dart`. Pattern:
|
||||
```dart
|
||||
await _testExec('label', binary, ['args'], workDir);
|
||||
// or
|
||||
_addResult('label', boolCondition, 'detail string');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
lib/
|
||||
main.dart # Flutter entry point
|
||||
app.dart # Root layout, workspace, panels
|
||||
clide.dart # Barrel: shared types
|
||||
src/ # Core subsystems (IPC, PTY, git, files, pql, panes)
|
||||
kernel/ # Kernel services (theme, i18n, settings, panels)
|
||||
builtin/ # Built-in extensions
|
||||
widgets/ # Custom widget primitives
|
||||
extension/ # Extension contract and registration
|
||||
lua/ # Lua runtime support
|
||||
|
||||
test/ # All tests (core + widgets + goldens + a11y)
|
||||
assets/ # Fonts, themes, grammars, licenses, logo
|
||||
linux/, macos/, web/ # Flutter platform directories
|
||||
native/ # Vendored native libs (tree-sitter, dugite)
|
||||
governance/ # D/Q/R records
|
||||
docs/ # Design docs, wireframes
|
||||
legacy/ # Python Textual clide v1.2 (frozen)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency & Supply Chain Discipline
|
||||
|
||||
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party needs justification
|
||||
- **Exact-pinned in pubspec.yaml** — no caret ranges
|
||||
- **Advisories reviewed** before every bump
|
||||
- **pubspec.lock committed**
|
||||
- **Document every bundled dependency** in `assets/licenses.yaml`:
|
||||
- name, kind, version, homepage, license, purpose
|
||||
- Adding a dep: two-step commit — artefact AND `licenses.yaml` entry
|
||||
- Native deps (dugite, libtree-sitter): vendored in `native/`, pinned by SHA
|
||||
|
||||
---
|
||||
|
||||
## Pre-push Gate
|
||||
|
||||
```bash
|
||||
make push-check
|
||||
```
|
||||
|
||||
Runs: decisions validation + core tests + fast suite + a11y + coverage + changelog
|
||||
|
||||
**Never bypass.** If it fails, fix the underlying issue.
|
||||
|
||||
---
|
||||
|
||||
## Session Setup
|
||||
|
||||
One-time setup on fresh clone:
|
||||
```bash
|
||||
make hooks && flutter pub get
|
||||
pql init # wires up pql skill + perms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mistral-Specific Notes
|
||||
|
||||
### What to preserve from Claude
|
||||
|
||||
- CLI-first interaction model
|
||||
- Strict guardrails enforcement
|
||||
- Logically-separated commits
|
||||
- Changelog discipline (60-word cap)
|
||||
- pql as single source of truth for decisions/tickets
|
||||
- UI/CLI parity
|
||||
- Testmode for integration validation
|
||||
|
||||
### What to adapt
|
||||
|
||||
- **Tool names:** Claude's `Bash(...)` → my `bash` tool
|
||||
- **Agent spawning:** Claude's `Agent{}` → my `task` tool for subagents
|
||||
- **Permissions:** `.claude/settings.json` allow/deny → my system prompt + your instructions
|
||||
|
||||
### When in doubt
|
||||
|
||||
Ask: "What would Claude do?" Then do that. The AGENTS.md is the bridge,
|
||||
not a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Discover clide commands | `clide capabilities` |
|
||||
| See current UI state | `clide status` |
|
||||
| Open ticket in UI | `clide ui open tickets T-NNN` |
|
||||
| Toast notification | `clide ui toast "msg" --severity info` |
|
||||
| List actionable tickets | `pql ticket list --under <id> --leaf --unblocked --status backlog --pretty` |
|
||||
| Sync decisions | `pql decisions sync` |
|
||||
| Run integration tests | `make run-testmode` |
|
||||
| Fast test suite | `make test` |
|
||||
| Full pre-push | `make push-check` |
|
||||
| Commit with message | `git commit -m "$(cat <<'EOF'\n<message>\nEOF\n)"` |
|
||||
| Verify changelog | `make changelog-gate` |
|
||||
|
||||
---
|
||||
|
||||
*Generated for Mistral Vibe. Preserves Claude Code punch for clide repo.*
|
||||
@@ -22,6 +22,115 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.9.0] — 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Graph cards.** `clide draw --file graph.json` (template `graph`) renders a
|
||||
nodes/edges graph in the conversation as a circular layout — labelled nodes,
|
||||
lines between them. Honest error on a duplicate id or an edge to an unknown
|
||||
node. (T-321)
|
||||
- **Piped `--stdin` payloads.** `cat icons.json | clide icon show --stdin` (and
|
||||
`image show`) accept a JSON payload on stdin — the ergonomic peer of `--file`
|
||||
for structured commands. (T-315)
|
||||
- **Before/after compare cards.** `clide draw --file compare.json` (template
|
||||
`compare`) renders two or more images side by side, each with its own
|
||||
label/description and tap-to-zoom; images aspect-fit so differing shapes
|
||||
don't distort. (T-319)
|
||||
- **Phosphor glyph cards.** `clide icon show gear folder` (or `--file` entries
|
||||
with label/description/color) renders glyphs in the conversation at a hero
|
||||
size plus a real-UI-size strip (10–48), for previewing and comparing icons.
|
||||
Resolves by name or 0xNNNN codepoint; honest error on an unknown glyph. (T-313)
|
||||
- **Annotated image cards.** `clide image show --file meta.json` attaches a
|
||||
title/label and a longer description to an image card (alongside the existing
|
||||
one-line caption); the bare `image show <path> --caption` form is unchanged.
|
||||
Visual marker overlays remain a follow-up. (T-316)
|
||||
- **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to
|
||||
SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"`
|
||||
with inline source works too, and a `.svg` file renders directly. Honest error
|
||||
if d2 isn't installed or the source doesn't compile. (T-494, D-103)
|
||||
- **Tool path resolution + settings.** clide resolves supporter binaries (claude,
|
||||
d2) via an explicit per-tool path, else PATH and the common install dirs — now
|
||||
including Homebrew-on-Linux. A Tools settings category edits the paths and
|
||||
re-detects; a broken path shows in Problems. (T-495, D-104)
|
||||
- **Check for updates (About box).** Help → About has a manual "Check for
|
||||
updates" button that compares your version to the latest GitHub release and
|
||||
links to the notes — explicit and user-initiated, no background polling (the
|
||||
first and only outbound call clide makes, on your action). (T-47)
|
||||
- **Live-sync markdown read-mirror.** Editing a `.md` in the editor auto-opens a
|
||||
read-only preview in the context panel that mirrors the buffer and re-renders
|
||||
as you type; non-renderable files get no auto-viewer. (T-36, D-50)
|
||||
- **New project flow.** A "New project…" action in the welcome view creates +
|
||||
opens a project, then prompts for the Claude account to bind it to — the
|
||||
per-repo account roadblock fires only for freshly-created projects. (T-488,
|
||||
story T-486)
|
||||
- **Initialize a non-repo folder.** Opening a folder that isn't a git repo now
|
||||
offers to initialize it as a clide project (`git init` + scaffold + the
|
||||
account roadblock) instead of dead-ending. Also `clide project init`. (T-489)
|
||||
- **`clide project new <name> [--dir <parent>]`.** Create a new clide project —
|
||||
a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the
|
||||
current workspace's parent. (T-487, story T-486)
|
||||
- **Claude account login pane.** `account login` (and the UI add/re-login
|
||||
affordances) open a modal terminal running `CLAUDE_CONFIG_DIR=<dir> claude
|
||||
login`; the CLI owns the OAuth flow, credentials land in that account's dir.
|
||||
(T-485, epic T-476)
|
||||
- **Claude pane account badge.** The pane header shows which account this repo
|
||||
is bound to (colour-tinted per account); tap to switch. Hidden when no
|
||||
accounts are registered. (T-481, epic T-476)
|
||||
- **Settings → Claude → Accounts.** A registry list (sign-in status, dir,
|
||||
re-login / remove, add) plus a per-workspace picker that binds this repo to an
|
||||
account (or Default); switching respawns the pane onto it. (T-482, epic T-476)
|
||||
- **Per-repo Claude accounts — `clide claude account` verbs.** Manage named
|
||||
Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/
|
||||
`remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto
|
||||
the bound account (resuming the conversation under its `CLAUDE_CONFIG_DIR`).
|
||||
(T-480, epic T-476)
|
||||
- **`clide instances` / `clide instance` CLI verbs.** `instances` lists every
|
||||
live clide on the machine with its version, pid, workspace, and socket path
|
||||
(probing the runtime dir); `instance` reports the one you're connected to — so
|
||||
you can find and target a specific instance via `CLIDE_SOCK`. (T-247)
|
||||
- **Summer Night theme + high-contrast sibling.** The legacy v1.2 palette is
|
||||
fleshed out to full token + syntax coverage and ships alongside a
|
||||
contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance.
|
||||
(T-478)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fresh Claude sessions are nudged to use the bundled skills.** New tabs and
|
||||
post-`/clear` respawns get a one-line prompt to load the `pql` + `clide`
|
||||
skills from the first turn; resumed/forked sessions are left alone. (T-490)
|
||||
- **Claude meta sidebar facelift.** The Activity, Team, and Config tabs render
|
||||
their sections as elevated cards with small-caps headers, matching the
|
||||
settings overlay's card design. (T-158)
|
||||
- **Bundled themes and Tier-0 i18n namespaces resolve from one canonical list
|
||||
each.** The app, testmode harness, and contrast/i18n gates iterate the shared
|
||||
lists instead of drifting copies; a new theme or catalog is validated
|
||||
automatically, and the i18n gate now checks every catalog for en/nl parity.
|
||||
(T-371)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No more i18n "missing key" log spam for tool names.** Proper-name tools
|
||||
(Bash, ScheduleWakeup, MCP tools, …) intentionally have no catalog entry and
|
||||
fall back to the raw name; the conversation pane no longer logs a warning for
|
||||
each. (T-493)
|
||||
- **`clide` CLI honors `CLIDE_SOCK`.** The shell client now connects to the
|
||||
socket named by `CLIDE_SOCK` when set — an explicit target (e.g. a spawned
|
||||
agent pinning its parent instance) that beats workspace auto-discovery — and
|
||||
fails loudly if that socket is dead instead of silently driving a different
|
||||
instance. (T-247)
|
||||
- **Orphaned IPC sockets are swept on startup.** The app probes the runtime
|
||||
socket dir on launch and unlinks dead `*.sock` nodes left by crashed
|
||||
instances (live instances are left untouched), so the dir no longer
|
||||
accumulates stale sockets. (T-247)
|
||||
- **Default window opens larger (1600×900) on Linux.** 720p was short enough
|
||||
that the welcome screen's version/theme footer overlapped the tips card;
|
||||
the taller default clears it, matching the macOS default. (T-477)
|
||||
- **Editor split collapses when the last buffer closes.** Closing the final
|
||||
editor buffer left the top split orphaned over the Claude pane; the daemon
|
||||
now emits the buffer-cleared event it was suppressing, so the split drops
|
||||
out and the primary pane fills the column. (T-459)
|
||||
|
||||
## [2.8.0] — 2026-06-19
|
||||
|
||||
### Added
|
||||
@@ -38,6 +147,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
locale under `assets/i18n/<locale>/`. A new language is a drop-in folder;
|
||||
en_US behaviour is unchanged. (T-462)
|
||||
|
||||
## [2.8.1] — 2026-06-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Localized the remaining Claude-pane strings.** Running-indicator verbs, the
|
||||
primary/secondary session titles and banner role, tool-card titles, the
|
||||
step/edit/agent counters, and the folded-activity ticker now resolve through
|
||||
the catalog — completing i18n coverage of the conversation surface. (T-462)
|
||||
|
||||
## [2.7.1] — 2026-06-18
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -169,8 +169,7 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||
|
||||
.PHONY: build
|
||||
build: gen-build-info clide-cli ## flutter build for the current OS (incl. the C CLI client).
|
||||
flutter build $(FLUTTER_OS)
|
||||
build: clide-cli build-$(FLUTTER_OS) ## flutter build for the current OS (via build-<os>) + bundle the C CLI client.
|
||||
@install -m 755 $(CLIDE_CLI_BIN) $(CLI_BUNDLE_DEST)
|
||||
@echo "==> bundled C client at $(CLI_BUNDLE_DEST)"
|
||||
|
||||
@@ -186,6 +185,10 @@ build-macos: gen-build-info ## flutter build macos (desktop bundle).
|
||||
build-windows: gen-build-info ## flutter build windows (desktop bundle).
|
||||
flutter build windows
|
||||
|
||||
.PHONY: release
|
||||
release: ## Finalize a release: verify version/changelog/tree, run the gate, tag vX.Y.Z. Run after the `release vX.Y.Z` commit.
|
||||
ci/release.sh
|
||||
|
||||
# -- install / uninstall -----------------------------------------------------
|
||||
|
||||
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
||||
@@ -261,6 +264,8 @@ else ifeq ($(FLUTTER_OS),macos)
|
||||
endif
|
||||
|
||||
# -- dugite-native (bundled git) ------------------------------------------
|
||||
# Security tracking (D-59): run `make dugite-check` quarterly, or on a git CVE,
|
||||
# to compare this pin against the latest upstream release. T-88 is the calendar.
|
||||
|
||||
DUGITE_VERSION := v2.53.0-3
|
||||
DUGITE_COMMIT := f49d009
|
||||
@@ -327,6 +332,10 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
|
||||
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
|
||||
|
||||
.PHONY: dugite-check
|
||||
dugite-check: ## Track dugite-native (bundled git) upstream releases for security drift (T-88 / D-59). Run quarterly, or on a git CVE. Informational, not a gate.
|
||||
ci/check_dugite_version.sh
|
||||
|
||||
# -- pre-push gate --------------------------------------------------------
|
||||
|
||||
.PHONY: decisions-validate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# clide
|
||||
|
||||
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux, macOS and Windows.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -6,13 +6,37 @@
|
||||
"status.primary-exited": { "translation": "session exited — restart clide to retry" },
|
||||
"banner.title": { "translation": "Claude" },
|
||||
"banner.warmingUp": { "translation": "Warming up — your conversation will appear here." },
|
||||
"banner.role.primary": { "translation": "primary" },
|
||||
"banner.role.secondary": { "translation": "session {index}" },
|
||||
"composer.hint": { "translation": "Message Claude… (Enter to send · Shift+Enter for newline)" },
|
||||
"composer.stop": { "translation": "Stop ⎋" },
|
||||
"composer.stop.hint": { "translation": "Interrupt the running turn (Escape)" },
|
||||
"composer.removeAttachment": { "translation": "Remove {name}" },
|
||||
"pane.title.primary": { "translation": "claude — primary" },
|
||||
"pane.title.secondary": { "translation": "claude — secondary {index}" },
|
||||
"pane.starting": { "translation": "starting…" },
|
||||
"pane.modeBadge.semantics": { "translation": "permission mode: {mode}" },
|
||||
"running.semantics": { "translation": "Claude is running" },
|
||||
"running.verb.pondering": { "translation": "Pondering" },
|
||||
"running.verb.conjuring": { "translation": "Conjuring" },
|
||||
"running.verb.brewing": { "translation": "Brewing" },
|
||||
"running.verb.tinkering": { "translation": "Tinkering" },
|
||||
"running.verb.noodling": { "translation": "Noodling" },
|
||||
"running.verb.percolating": { "translation": "Percolating" },
|
||||
"running.verb.computing": { "translation": "Computing" },
|
||||
"running.verb.wrangling": { "translation": "Wrangling" },
|
||||
"running.verb.untangling": { "translation": "Untangling" },
|
||||
"running.verb.synthesizing": { "translation": "Synthesizing" },
|
||||
"running.verb.cogitating": { "translation": "Cogitating" },
|
||||
"running.verb.whirring": { "translation": "Whirring" },
|
||||
"running.verb.mincing": { "translation": "Mincing" },
|
||||
"running.verb.boiling": { "translation": "Boiling" },
|
||||
"running.verb.humming": { "translation": "Humming" },
|
||||
"running.verb.buzzing": { "translation": "Buzzing" },
|
||||
"running.verb.magicking": { "translation": "Magicking" },
|
||||
"running.verb.cliding": { "translation": "Cliding" },
|
||||
"running.verb.zooming": { "translation": "Zooming" },
|
||||
"running.verb.bouncing": { "translation": "Bouncing" },
|
||||
"conversation.empty": { "translation": "Waiting for Claude…" },
|
||||
"conversation.label.you": { "translation": "you" },
|
||||
"conversation.label.claude": { "translation": "claude" },
|
||||
@@ -23,6 +47,9 @@
|
||||
"conversation.label.thinking": { "translation": "thinking" },
|
||||
"conversation.label.agentThinking": { "translation": "agent thinking" },
|
||||
"conversation.label.image": { "translation": "image" },
|
||||
"conversation.label.icon": { "translation": "icons" },
|
||||
"conversation.label.drawing": { "translation": "drawing" },
|
||||
"conversation.draw.viewSource": { "translation": "view d2 source" },
|
||||
"conversation.label.agentRun": { "translation": "agent run" },
|
||||
"conversation.label.workflow": { "translation": "workflow" },
|
||||
"conversation.label.error": { "translation": "error" },
|
||||
@@ -39,6 +66,12 @@
|
||||
"conversation.bashTail.label": { "translation": "live tail" },
|
||||
"conversation.cluster.activity": { "translation": "Activity" },
|
||||
"conversation.cluster.edits": { "translation": "Edits" },
|
||||
"conversation.counter.step": { "translation": "1 step" },
|
||||
"conversation.counter.steps": { "translation": "{count} steps" },
|
||||
"conversation.counter.edit": { "translation": "1 edit" },
|
||||
"conversation.counter.edits": { "translation": "{count} edits" },
|
||||
"conversation.counter.starting": { "translation": "starting" },
|
||||
"conversation.counter.agents": { "translation": "{done}/{total} agents" },
|
||||
"prompt.permission.allow": { "translation": "1. Allow" },
|
||||
"prompt.permission.allowRemember": { "translation": "2. Allow & don't ask again" },
|
||||
"prompt.permission.deny": { "translation": "{n}. Deny" },
|
||||
@@ -62,6 +95,16 @@
|
||||
"tool.edit.before": { "translation": "— before" },
|
||||
"tool.edit.after": { "translation": "+ after" },
|
||||
"tool.bash.background": { "translation": "background" },
|
||||
"tool.name.Read": { "translation": "Read" },
|
||||
"tool.name.Edit": { "translation": "Edit" },
|
||||
"tool.name.MultiEdit": { "translation": "MultiEdit" },
|
||||
"tool.name.Write": { "translation": "Write" },
|
||||
"tool.name.NotebookEdit": { "translation": "NotebookEdit" },
|
||||
"tool.name.WebFetch": { "translation": "WebFetch" },
|
||||
"tool.name.WebSearch": { "translation": "WebSearch" },
|
||||
"tool.name.Task": { "translation": "Task" },
|
||||
"tool.name.TodoWrite": { "translation": "TodoWrite" },
|
||||
"tool.name.ExitPlanMode": { "translation": "ExitPlanMode" },
|
||||
"permissionControl.semantics": { "translation": "permission mode: {mode}. Activate to change." },
|
||||
"permissionControl.tooltip": { "translation": "Permission mode: {mode} — change (Ctrl/Cmd+M cycles)" },
|
||||
"permissionBadge.tooltip": { "translation": "Permission mode: {mode}. Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions." },
|
||||
@@ -197,5 +240,10 @@
|
||||
"settings.claude.effort.label": { "translation": "Effort" },
|
||||
"settings.claude.effort.help": { "translation": "Reasoning effort for new sessions (applied via --effort at spawn)." },
|
||||
"settings.claude.permissionMode.label": { "translation": "Permission mode" },
|
||||
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." }
|
||||
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." },
|
||||
"settings.claude.account.label": { "translation": "Account" },
|
||||
"settings.claude.account.registry.label": { "translation": "Accounts" },
|
||||
"settings.claude.account.registry.help": { "translation": "Registered Claude accounts (each a separate config dir + login)." },
|
||||
"settings.claude.account.workspace.label": { "translation": "Account for this workspace" },
|
||||
"settings.claude.account.workspace.help": { "translation": "Which Claude account this repo runs under; Default uses the system login." }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"about.commit": { "translation": "Commit" },
|
||||
"about.built": { "translation": "Built" },
|
||||
"about.repository": { "translation": "Repository" },
|
||||
"about.checkUpdates": { "translation": "Check for updates" },
|
||||
"about.checking": { "translation": "Checking…" },
|
||||
"about.upToDate": { "translation": "You're on the latest version." },
|
||||
"about.updateAvailable": { "translation": "clide {version} is available — release notes" },
|
||||
"about.updateFailed": { "translation": "Couldn't check for updates" },
|
||||
"licenses.heading": { "translation": "Bundled dependencies" },
|
||||
"licenses.unavailable": { "translation": "Licenses unavailable." },
|
||||
"licenses.loading": { "translation": "Loading…" },
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"command.detect": { "translation": "Re-detect tool paths" },
|
||||
"settings.title": { "translation": "Tools" },
|
||||
"settings.section.binaries": { "translation": "Supporter binaries" },
|
||||
"settings.field.claude.label": { "translation": "Claude CLI" },
|
||||
"settings.field.claude.help": { "translation": "Absolute path to the claude binary; blank to auto-resolve." },
|
||||
"settings.field.d2.label": { "translation": "d2" },
|
||||
"settings.field.d2.help": { "translation": "Absolute path to the d2 diagram compiler; blank to auto-resolve." },
|
||||
"settings.field.detect.label": { "translation": "Re-detect" },
|
||||
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." }
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
"tips.findInFiles": { "translation": "Find in files" },
|
||||
"tips.focusMode": { "translation": "Focus mode" },
|
||||
"action.openFolder": { "translation": "Open folder…" },
|
||||
"action.newProject": { "translation": "New project…" },
|
||||
"dialog.newProject.title": { "translation": "New project" },
|
||||
"dialog.newProject.body": { "translation": "Creates a git repo + a CLAUDE.md, then opens it." },
|
||||
"button.create": { "translation": "Create" },
|
||||
"button.creating": { "translation": "Creating…" },
|
||||
"recent.empty": { "translation": "No recent projects." },
|
||||
"sticky.label": { "translation": "always open this project on launch" },
|
||||
"sticky.tooltip": { "translation": "Always open this project on launch" },
|
||||
@@ -30,6 +35,8 @@
|
||||
"button.opening": { "translation": "Opening…" },
|
||||
"button.ok": { "translation": "OK" },
|
||||
"dialog.notRepo.title": { "translation": "No git repo found" },
|
||||
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." },
|
||||
"dialog.notRepo.body": { "translation": "A clide project needs a git repository. Initialize this folder as one?" },
|
||||
"dialog.notRepo.initialize": { "translation": "Initialize project" },
|
||||
"button.initializing": { "translation": "Initializing…" },
|
||||
"command.workspace.open-project": { "translation": "Workspace: Open project…" }
|
||||
}
|
||||
|
||||
@@ -6,13 +6,37 @@
|
||||
"status.primary-exited": { "translation": "sessie beëindigd — herstart clide om opnieuw te proberen" },
|
||||
"banner.title": { "translation": "Claude" },
|
||||
"banner.warmingUp": { "translation": "Opstarten — je gesprek verschijnt hier." },
|
||||
"banner.role.primary": { "translation": "primair" },
|
||||
"banner.role.secondary": { "translation": "sessie {index}" },
|
||||
"composer.hint": { "translation": "Bericht aan Claude… (Enter om te verzenden · Shift+Enter voor een nieuwe regel)" },
|
||||
"composer.stop": { "translation": "Stop ⎋" },
|
||||
"composer.stop.hint": { "translation": "Onderbreek de lopende beurt (Escape)" },
|
||||
"composer.removeAttachment": { "translation": "{name} verwijderen" },
|
||||
"pane.title.primary": { "translation": "claude — primair" },
|
||||
"pane.title.secondary": { "translation": "claude — secundair {index}" },
|
||||
"pane.starting": { "translation": "starten…" },
|
||||
"pane.modeBadge.semantics": { "translation": "permissiemodus: {mode}" },
|
||||
"running.semantics": { "translation": "Claude is bezig" },
|
||||
"running.verb.pondering": { "translation": "Peinzen" },
|
||||
"running.verb.conjuring": { "translation": "Toveren" },
|
||||
"running.verb.brewing": { "translation": "Brouwen" },
|
||||
"running.verb.tinkering": { "translation": "Knutselen" },
|
||||
"running.verb.noodling": { "translation": "Prutsen" },
|
||||
"running.verb.percolating": { "translation": "Pruttelen" },
|
||||
"running.verb.computing": { "translation": "Rekenen" },
|
||||
"running.verb.wrangling": { "translation": "Worstelen" },
|
||||
"running.verb.untangling": { "translation": "Ontwarren" },
|
||||
"running.verb.synthesizing": { "translation": "Synthetiseren" },
|
||||
"running.verb.cogitating": { "translation": "Overpeinzen" },
|
||||
"running.verb.whirring": { "translation": "Snorren" },
|
||||
"running.verb.mincing": { "translation": "Hakken" },
|
||||
"running.verb.boiling": { "translation": "Koken" },
|
||||
"running.verb.humming": { "translation": "Neuriën" },
|
||||
"running.verb.buzzing": { "translation": "Zoemen" },
|
||||
"running.verb.magicking": { "translation": "Goochelen" },
|
||||
"running.verb.cliding": { "translation": "Cliden" },
|
||||
"running.verb.zooming": { "translation": "Zoeven" },
|
||||
"running.verb.bouncing": { "translation": "Stuiteren" },
|
||||
"conversation.empty": { "translation": "Wachten op Claude…" },
|
||||
"conversation.label.you": { "translation": "jij" },
|
||||
"conversation.label.claude": { "translation": "claude" },
|
||||
@@ -23,6 +47,9 @@
|
||||
"conversation.label.thinking": { "translation": "nadenken" },
|
||||
"conversation.label.agentThinking": { "translation": "agent denkt na" },
|
||||
"conversation.label.image": { "translation": "afbeelding" },
|
||||
"conversation.label.icon": { "translation": "iconen" },
|
||||
"conversation.label.drawing": { "translation": "tekening" },
|
||||
"conversation.draw.viewSource": { "translation": "d2-bron tonen" },
|
||||
"conversation.label.agentRun": { "translation": "agent-uitvoering" },
|
||||
"conversation.label.workflow": { "translation": "workflow" },
|
||||
"conversation.label.error": { "translation": "fout" },
|
||||
@@ -39,6 +66,12 @@
|
||||
"conversation.bashTail.label": { "translation": "live tail" },
|
||||
"conversation.cluster.activity": { "translation": "Activiteit" },
|
||||
"conversation.cluster.edits": { "translation": "Wijzigingen" },
|
||||
"conversation.counter.step": { "translation": "1 stap" },
|
||||
"conversation.counter.steps": { "translation": "{count} stappen" },
|
||||
"conversation.counter.edit": { "translation": "1 wijziging" },
|
||||
"conversation.counter.edits": { "translation": "{count} wijzigingen" },
|
||||
"conversation.counter.starting": { "translation": "starten" },
|
||||
"conversation.counter.agents": { "translation": "{done}/{total} agents" },
|
||||
"prompt.permission.allow": { "translation": "1. Toestaan" },
|
||||
"prompt.permission.allowRemember": { "translation": "2. Toestaan en niet meer vragen" },
|
||||
"prompt.permission.deny": { "translation": "{n}. Weigeren" },
|
||||
@@ -62,6 +95,16 @@
|
||||
"tool.edit.before": { "translation": "— voor" },
|
||||
"tool.edit.after": { "translation": "+ na" },
|
||||
"tool.bash.background": { "translation": "achtergrond" },
|
||||
"tool.name.Read": { "translation": "Lezen" },
|
||||
"tool.name.Edit": { "translation": "Bewerken" },
|
||||
"tool.name.MultiEdit": { "translation": "Meervoudig bewerken" },
|
||||
"tool.name.Write": { "translation": "Schrijven" },
|
||||
"tool.name.NotebookEdit": { "translation": "Notebook bewerken" },
|
||||
"tool.name.WebFetch": { "translation": "Web ophalen" },
|
||||
"tool.name.WebSearch": { "translation": "Web zoeken" },
|
||||
"tool.name.Task": { "translation": "Taak" },
|
||||
"tool.name.TodoWrite": { "translation": "Takenlijst" },
|
||||
"tool.name.ExitPlanMode": { "translation": "Planmodus verlaten" },
|
||||
"permissionControl.semantics": { "translation": "permissiemodus: {mode}. Activeer om te wijzigen." },
|
||||
"permissionControl.tooltip": { "translation": "Permissiemodus: {mode} — wijzigen (Ctrl/Cmd+M wisselt)" },
|
||||
"permissionBadge.tooltip": { "translation": "Permissiemodus: {mode}. Klik om te wisselen tussen default/acceptEdits/plan; Shift-klik voor bypassPermissions." },
|
||||
@@ -197,5 +240,10 @@
|
||||
"settings.claude.effort.label": { "translation": "Effort" },
|
||||
"settings.claude.effort.help": { "translation": "Reasoning effort voor nieuwe sessies (toegepast via --effort bij het starten)." },
|
||||
"settings.claude.permissionMode.label": { "translation": "Permissiemodus" },
|
||||
"settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." }
|
||||
"settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." },
|
||||
"settings.claude.account.label": { "translation": "Account" },
|
||||
"settings.claude.account.registry.label": { "translation": "Accounts" },
|
||||
"settings.claude.account.registry.help": { "translation": "Geregistreerde Claude-accounts (elk een eigen configmap + login)." },
|
||||
"settings.claude.account.workspace.label": { "translation": "Account voor deze werkmap" },
|
||||
"settings.claude.account.workspace.help": { "translation": "Welk Claude-account deze repo gebruikt; Standaard gebruikt de systeemlogin." }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"about.commit": { "translation": "Commit" },
|
||||
"about.built": { "translation": "Gebouwd" },
|
||||
"about.repository": { "translation": "Repository" },
|
||||
"about.checkUpdates": { "translation": "Controleer op updates" },
|
||||
"about.checking": { "translation": "Bezig met controleren…" },
|
||||
"about.upToDate": { "translation": "Je hebt de nieuwste versie." },
|
||||
"about.updateAvailable": { "translation": "clide {version} is beschikbaar — releaseopmerkingen" },
|
||||
"about.updateFailed": { "translation": "Kon niet op updates controleren" },
|
||||
"licenses.heading": { "translation": "Meegeleverde afhankelijkheden" },
|
||||
"licenses.unavailable": { "translation": "Licenties niet beschikbaar." },
|
||||
"licenses.loading": { "translation": "Laden…" },
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"command.detect": { "translation": "Tool-paden opnieuw detecteren" },
|
||||
"settings.title": { "translation": "Tools" },
|
||||
"settings.section.binaries": { "translation": "Hulpprogramma's" },
|
||||
"settings.field.claude.label": { "translation": "Claude-CLI" },
|
||||
"settings.field.claude.help": { "translation": "Absoluut pad naar het claude-binary; leeg om automatisch te bepalen." },
|
||||
"settings.field.d2.label": { "translation": "d2" },
|
||||
"settings.field.d2.help": { "translation": "Absoluut pad naar de d2-diagramcompiler; leeg om automatisch te bepalen." },
|
||||
"settings.field.detect.label": { "translation": "Opnieuw detecteren" },
|
||||
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." }
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
"tips.findInFiles": { "translation": "Zoeken in bestanden" },
|
||||
"tips.focusMode": { "translation": "Focusmodus" },
|
||||
"action.openFolder": { "translation": "Map openen…" },
|
||||
"action.newProject": { "translation": "Nieuw project…" },
|
||||
"dialog.newProject.title": { "translation": "Nieuw project" },
|
||||
"dialog.newProject.body": { "translation": "Maakt een git-repo + een CLAUDE.md, en opent het." },
|
||||
"button.create": { "translation": "Aanmaken" },
|
||||
"button.creating": { "translation": "Aanmaken…" },
|
||||
"recent.empty": { "translation": "Geen recente projecten." },
|
||||
"sticky.label": { "translation": "dit project altijd openen bij opstarten" },
|
||||
"sticky.tooltip": { "translation": "Dit project altijd openen bij opstarten" },
|
||||
@@ -30,6 +35,8 @@
|
||||
"button.opening": { "translation": "Openen…" },
|
||||
"button.ok": { "translation": "OK" },
|
||||
"dialog.notRepo.title": { "translation": "Geen git-repo gevonden" },
|
||||
"dialog.notRepo.body": { "translation": "Een clide-projecthoofdmap vereist een git-repository." },
|
||||
"dialog.notRepo.body": { "translation": "Een clide-project heeft een git-repository nodig. Deze map als project initialiseren?" },
|
||||
"dialog.notRepo.initialize": { "translation": "Project initialiseren" },
|
||||
"button.initializing": { "translation": "Initialiseren…" },
|
||||
"command.workspace.open-project": { "translation": "Werkruimte: Project openen…" }
|
||||
}
|
||||
|
||||
@@ -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.8.0"
|
||||
version: "2.9.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# T-88 — track dugite-native (bundled git) upstream releases for security
|
||||
# updates (D-59). Compares the pinned DUGITE_VERSION against the latest
|
||||
# desktop/dugite-native release and flags CVE / security mentions.
|
||||
#
|
||||
# WHY THIS SCRIPT IS THE MAINTENANCE HOME: dugite's binary is FETCHED at build
|
||||
# time (`make dugite-fetch`), not built, and native/dugite/ is gitignored — so
|
||||
# there is no `native/dugite/BUILD.md` (D-63) to record it. The pin lives in the
|
||||
# Makefile (DUGITE_VERSION / DUGITE_COMMIT); this script + its `make dugite-check`
|
||||
# target are the version-tracking calendar D-59 requires.
|
||||
#
|
||||
# CADENCE: run quarterly during normal operation. Run IMMEDIATELY on a git or
|
||||
# dugite-native security advisory — subscribe to:
|
||||
# https://github.com/git/git/security/advisories
|
||||
# https://github.com/desktop/dugite-native/security/advisories
|
||||
#
|
||||
# This check is INFORMATIONAL (not a push gate): it reports drift and flags CVE
|
||||
# mentions. The bump itself is manual per D-63 (reproducibility record) and is
|
||||
# automated by T-25 (CI). A bump updates: Makefile DUGITE_VERSION/COMMIT, the
|
||||
# fetched binary in native/dugite/, and assets/licenses.yaml if the bundled git
|
||||
# or dugite version changed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
REPO="desktop/dugite-native"
|
||||
CURRENT="$(grep -E '^DUGITE_VERSION[[:space:]]*:=' Makefile | head -1 | sed -E 's/.*:=[[:space:]]*//')"
|
||||
|
||||
# Public read — no auth needed for a quarterly check. gh would raise the rate
|
||||
# limit but isn't required; curl keeps this dependency-free.
|
||||
json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null || true)"
|
||||
LATEST="$(printf '%s' "$json" | sed -nE 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/p' | head -1)"
|
||||
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
echo "==> dugite-check: couldn't reach the dugite-native releases API." >&2
|
||||
echo " Check manually: https://github.com/$REPO/releases" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "==> dugite-check (T-88): bundled '$CURRENT' vs latest release '$LATEST'"
|
||||
if [[ "$CURRENT" == "$LATEST" ]]; then
|
||||
echo " OK — up to date."
|
||||
else
|
||||
echo " DRIFT — a newer dugite-native release exists: $CURRENT -> $LATEST"
|
||||
echo " Bump (D-63 record; machine: T-25): update Makefile DUGITE_VERSION/COMMIT,"
|
||||
echo " refresh native/dugite/, and assets/licenses.yaml if the git/dugite version changed."
|
||||
fi
|
||||
|
||||
# Loud flag when the latest release notes mention a CVE / security fix — those
|
||||
# jump the queue regardless of the quarterly cadence.
|
||||
if printf '%s' "$json" | grep -qiE 'cve-[0-9]{4}|security (fix|advisory|release|update)|vulnerab'; then
|
||||
echo " !! SECURITY: the latest release notes mention a CVE / security fix — schedule a bump NOW." >&2
|
||||
fi
|
||||
@@ -1,9 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI entry: release pipeline. Stub — wire goreleaser + flutter build
|
||||
# artifacts later.
|
||||
# Release finalizer for the single-process Flutter app (T-393).
|
||||
#
|
||||
# Run AFTER the `release vX.Y.Z` commit is in place (version bump + changelog
|
||||
# move — see .claude/skills/git-commit/SKILL.md "Cutting a release"). It:
|
||||
# 1. reads the version from pubspec.yaml,
|
||||
# 2. asserts CHANGELOG.md has a dated section for it (not still Unreleased),
|
||||
# 3. asserts the working tree is clean,
|
||||
# 4. runs the full gate (make push-check),
|
||||
# 5. creates the annotated vX.Y.Z tag if missing — closing the loop that
|
||||
# previously left every release since v2.1.0 untagged.
|
||||
#
|
||||
# No goreleaser, no sidecar (both dissolved, D-56). Artifact builds are
|
||||
# `make build-linux` / `make build-macos`. This never pushes — it prints the
|
||||
# push command for you to run.
|
||||
#
|
||||
# Invoke via `make release`, not directly.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "TODO: goreleaser release (sidecar) + flutter build (app) + publish"
|
||||
exit 64
|
||||
version="$(grep -E '^version:' pubspec.yaml | awk '{print $2}')"
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "release: could not read 'version:' from pubspec.yaml" >&2
|
||||
exit 1
|
||||
fi
|
||||
tag="v$version"
|
||||
|
||||
# 1. The release commit must already have moved Unreleased → [version] — DATE.
|
||||
if ! grep -qE "^## \[${version//./\\.}\] — [0-9]{4}-[0-9]{2}-[0-9]{2}" CHANGELOG.md; then
|
||||
echo "release: CHANGELOG.md has no dated section for [$version]." >&2
|
||||
echo " Cut the release commit first (move Unreleased → '## [$version] — YYYY-MM-DD')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Clean tree — the release commit is in, nothing dangling.
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "release: working tree not clean — commit the release first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Full gate.
|
||||
echo "release: running the full gate (make push-check)…"
|
||||
make push-check
|
||||
|
||||
# 4. Tag (idempotent), annotated, on the current release commit.
|
||||
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
|
||||
echo "release: tag $tag already exists — leaving it."
|
||||
else
|
||||
git tag -a "$tag" -m "clide $tag"
|
||||
echo "release: created tag $tag at $(git rev-parse --short HEAD)."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "release: $tag verified and tagged. Next:"
|
||||
echo " git push origin main --follow-tags # publish the commit + tag"
|
||||
echo " make build-linux # desktop bundle (Linux)"
|
||||
echo " make build-macos # desktop bundle (macOS, on a Mac)"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Drawing-card JSON schema (T-317 / D-91 / D-103) — draft
|
||||
|
||||
Status: **draft for build**, SVG-substrate model (D-103), refined from the
|
||||
T-317 wireframe set, 2026-06-28.
|
||||
|
||||
## Model — what this is, and what it is NOT
|
||||
|
||||
- **SVG is the substrate.** The card's primitive / scene-graph layer **is SVG**;
|
||||
the clide-owned `CustomPaint` **SVG renderer (T-320) is the engine** the rest
|
||||
builds on (D-103).
|
||||
- It is **not** the HTML Canvas 2D API and **not** a third-party package. SVG is
|
||||
a document *format* we render ourselves — "own the rendering stack" holds.
|
||||
("HTML `<canvas>`" in D-91 was only a mental model, chosen to reject Obsidian's
|
||||
`.canvas` schema; never an API to port.)
|
||||
- The card is **two layers**:
|
||||
1. **SVG content** — painted by the SVG renderer.
|
||||
2. A thin **Flutter overlay** — the clide chrome that is *not* content
|
||||
(per-object label/description captions, lightbox affordance), anchored to
|
||||
SVG elements via `data-*` attributes.
|
||||
- **Display-only** (D-78); re-rendered from the document. The **graph template
|
||||
is the live-widget exception** (below).
|
||||
|
||||
## Document envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"card": { "label": "Build pipeline", "description": "…" }, // optional caption (overlay)
|
||||
"template": "icon", // optional → template sugar
|
||||
"…template fields…": "…",
|
||||
"svg": "<svg viewBox='0 0 480 360'>…</svg>" // primitive mode = raw SVG
|
||||
// or "svgPath": "diagram.svg"
|
||||
}
|
||||
```
|
||||
|
||||
- **Template mode** — `template` names a component; clide lowers it to SVG
|
||||
(+ overlay anchors).
|
||||
- **Primitive mode** — `svg` (inline) or `svgPath` — arbitrary SVG, the escape
|
||||
hatch. One less invented format; external SVG / graphviz / mermaid render free.
|
||||
- Card size comes from the SVG `viewBox` (or `width`/`height`); the painter scales
|
||||
to the pane width.
|
||||
|
||||
## Primitive layer = a bounded SVG subset
|
||||
|
||||
Grounded in a real d2 sample + our own templates — **not** a full SVG engine:
|
||||
|
||||
- **structure:** `<svg>` (viewBox/width/height, incl. nested `<svg>`), `<g>`
|
||||
(transform, opacity, class), `<defs>`, `<marker>` (+ marker-start/mid/end,
|
||||
`orient="auto"`, refX/refY, viewBox) — edge **arrowheads**
|
||||
- **shapes:** `rect` (rx/ry), `circle`, `ellipse`, `line`, `polyline`,
|
||||
`polygon`, `path` (full data — `M L H V C S Q T A Z` + relatives)
|
||||
- **text:** `text` + `tspan` (x/y/dx/dy, font-family incl. **Phosphor**,
|
||||
font-size/weight, text-anchor, dominant-baseline)
|
||||
- **raster:** `image` (`href`/`xlink:href`, x/y/w/h, preserveAspectRatio)
|
||||
- **styling:** presentation attrs (fill, fill-opacity, stroke, stroke-width,
|
||||
stroke-linecap/linejoin, stroke-dasharray, opacity, color), `transform`
|
||||
(translate/scale/rotate/matrix); `class=` resolved by the normalizer below
|
||||
- **deferred v1:** `<mask>` (d2 masks connections for clean edge/node joins) —
|
||||
ignore and lean on node-over-edge paint order; add only if output looks wrong
|
||||
- **out:** `foreignObject`, filters, `<animate>`/SMIL, scripting,
|
||||
`<use>`/`<symbol>`, gradients, patterns, `clipPath` → **mermaid is not a
|
||||
launch target** (it leans on `foreignObject`)
|
||||
|
||||
`color` everywhere is an **arbitrary value** (hex / named) — content, not a clide
|
||||
`SurfaceTokens` token (D-7 governs clide chrome, not rendered content).
|
||||
|
||||
### Class styling → inline-normalize (not a render-time CSS engine)
|
||||
|
||||
d2 / graphviz emit a `<style>` block of flat single-class selectors
|
||||
(`.fill-B1`, `.shape`, `.connection`, `.text-bold` → presentation props), not
|
||||
inline attributes. A **preprocessing normalizer** parses `<style>` into
|
||||
class→props and merges each element's class props into inline presentation
|
||||
attributes (inline wins), then drops `<style>`. The painter therefore only ever
|
||||
sees inline attrs — a pure, testable presentation-attribute renderer. The
|
||||
normalizer is a bounded, fixture-testable transform (real d2 + graphviz output).
|
||||
|
||||
## Overlay (clide chrome, layered over the SVG)
|
||||
|
||||
Flutter widgets anchored to SVG elements that carry:
|
||||
|
||||
- `data-label` → themed caption beneath the element's bounding box
|
||||
- `data-description` → secondary caption line
|
||||
- `data-lightbox` (on `<image>`) → click-to-zoom affordance
|
||||
|
||||
Templates emit these attributes; raw-SVG authors may add them. The icon
|
||||
template's `data-label` is also the bridge to the interaction-zone choice list.
|
||||
|
||||
## Templates (lower to SVG + overlay)
|
||||
|
||||
| template | lowers to | ticket |
|
||||
|----------|-----------|--------|
|
||||
| `image` | `<image href>` + `data-lightbox` + caption attrs | T-316 |
|
||||
| `icon` | `<text font=Phosphor>` glyphs at 10,11,12,13,14,15,18,20,24,32,48 + hero 52; per-item `data-label`/`data-description`/color | T-313 |
|
||||
| `compare` | two+ `<image>` side by side in a `<g>`, per-image `data-lightbox` + captions | T-319 |
|
||||
| `svg` | identity — the source *is* the SVG | T-320 |
|
||||
| `d2` | compile d2 → SVG → render | T-494 |
|
||||
| `graph` | **exception** — hosts the live native graph subsystem widget (D-46 / T-323), not static SVG | T-321 |
|
||||
|
||||
## CLI / transport (D-6 parity)
|
||||
|
||||
`clide draw --file doc.json` (or inline JSON). **Flutter-free** handler validates,
|
||||
publishes on a `draw` MessageBus channel; the Claude extension injects the card —
|
||||
mirroring `image.show`. The shipped `image show` (T-249/T-252) stays as
|
||||
convenience and migrates onto this card later (D-91). `--stdin` deferred (T-315);
|
||||
`--file` is the path.
|
||||
|
||||
## Error contract
|
||||
|
||||
Unknown `template`, unparseable / unsupported SVG, bad `href` / glyph / `color`
|
||||
→ honest `IpcError` (`userError` / `notFound`), surfaced like `image.show` —
|
||||
**never a blank card**.
|
||||
|
||||
## Build sequence (D-103)
|
||||
|
||||
1. **T-320 — the SVG renderer (engine):** parse + paint the bounded SVG subset.
|
||||
2. **T-318 — envelope + template dispatch + the Flutter overlay** (`data-*` →
|
||||
captions / lightbox) on top of the renderer.
|
||||
3. **Templates:** image / icon (T-316 / T-313) → compare (T-319) → d2 (T-494) →
|
||||
graph (T-321, after the graph subsystem T-323).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **SVG subset boundary (T-320): RESOLVED** — see the subset above, grounded in
|
||||
a real d2 sample; expand deliberately.
|
||||
- **Class styling (T-320): RESOLVED** — inline-normalize, not a render-time CSS
|
||||
engine (above).
|
||||
- **Tool-PATH resolution: RESOLVED** — explicit user-scope override + first-run
|
||||
auto-detect (D-104 / T-495); the d2 binary resolves through it.
|
||||
- **D2 compiler delivery (T-494): open** — shell out to a `d2` binary as a
|
||||
pql-style supporter tool (resolution now handled by D-104), vs. vendor.
|
||||
- **Graph (T-321): gated** on the native graph subsystem (D-46 / T-323).
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "Drawing Card Core (T-318)",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 38, "text": "clide drawing card — core: canvas engine + JSON schema + template dispatch (T-318)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 60, "text": "the dispatch shell under every card type — a JSON document renders as raw primitives OR via a named template that lowers onto the same primitive scene (hybrid)", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
"cliLine": { "type": "Text", "left": 60, "top": 84, "text": "clide draw --file doc.json — Flutter-free handler → MessageBus → Claude-extension injection (mirrors image.show)", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"inputBox": { "type": "Rectangle", "left": 400, "top": 110, "width": 300, "height": 58, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
|
||||
"inT": { "type": "Text", "parent": "inputBox", "left": 418, "top": 122, "text": "Drawing-card JSON document", "fontColor": "#e2e8f5", "fontSize": 13 },
|
||||
"inT2": { "type": "Text", "parent": "inputBox", "left": 418, "top": 144, "text": "{ \"template\"?: \"…\", \"objects\": [ … ] }", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
|
||||
"coreBox": { "type": "Rectangle", "left": 360, "top": 200, "width": 380, "height": 60, "fillColor": "#1a1e24", "strokeColor": "#4a5570", "corners": [8, 8, 8, 8] },
|
||||
"coreT": { "type": "Text", "parent": "coreBox", "left": 384, "top": 212, "text": "Drawing-card core (T-318)", "fontColor": "#e2e8f5", "fontSize": 14 },
|
||||
"coreT2": { "type": "Text", "parent": "coreBox", "left": 384, "top": 234, "text": "JSON schema · primitive renderer · template dispatch", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
|
||||
"primBox": { "type": "Rectangle", "left": 110, "top": 312, "width": 360, "height": 150, "fillColor": "#161a20", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"primH": { "type": "Text", "parent": "primBox", "left": 130, "top": 326, "text": "PRIMITIVE scene-graph", "fontColor": "#e2e8f5", "fontSize": 13 },
|
||||
"primS": { "type": "Text", "parent": "primBox", "left": 130, "top": 348, "text": "objects drawn at coordinates", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
"pc1": { "type": "Rectangle", "parent": "primBox", "left": 130, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"pc1t": { "type": "Text", "parent": "pc1", "left": 144, "top": 382, "text": "rect", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"pc2": { "type": "Rectangle", "parent": "primBox", "left": 194, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"pc2t": { "type": "Text", "parent": "pc2", "left": 208, "top": 382, "text": "line", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"pc3": { "type": "Rectangle", "parent": "primBox", "left": 258, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"pc3t": { "type": "Text", "parent": "pc3", "left": 272, "top": 382, "text": "text", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"pc4": { "type": "Rectangle", "parent": "primBox", "left": 322, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"pc4t": { "type": "Text", "parent": "pc4", "left": 334, "top": 382, "text": "glyph", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"pc5": { "type": "Rectangle", "parent": "primBox", "left": 386, "top": 376, "width": 56, "height": 24, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"pc5t": { "type": "Text", "parent": "pc5", "left": 398, "top": 382, "text": "image", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"primNote": { "type": "Text", "parent": "primBox", "left": 130, "top": 420, "text": "{ \"type\": \"rect\", \"x\":.., \"y\":.., \"w\":.., \"h\":.. }", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
|
||||
"tmplBox": { "type": "Rectangle", "left": 640, "top": 312, "width": 360, "height": 212, "fillColor": "#161a20", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"tmplH": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 326, "text": "TEMPLATE dispatch", "fontColor": "#e2e8f5", "fontSize": 13 },
|
||||
"tmplS": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 348, "text": "named component → predefined renderer (children)", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
"tc1": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 374, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc1t": { "type": "Text", "parent": "tc1", "left": 674, "top": 381, "text": "image · T-316", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tc2": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 374, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc2t": { "type": "Text", "parent": "tc2", "left": 836, "top": 381, "text": "icon · T-313", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tc3": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 408, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc3t": { "type": "Text", "parent": "tc3", "left": 674, "top": 415, "text": "svg · T-320", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tc4": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 408, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc4t": { "type": "Text", "parent": "tc4", "left": 836, "top": 415, "text": "d2 · T-494", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tc5": { "type": "Rectangle", "parent": "tmplBox", "left": 660, "top": 442, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc5t": { "type": "Text", "parent": "tc5", "left": 674, "top": 449, "text": "compare · T-319", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tc6": { "type": "Rectangle", "parent": "tmplBox", "left": 822, "top": 442, "width": 150, "height": 26, "fillColor": "#2a3040", "strokeColor": "#3a4250", "corners": [4, 4, 4, 4] },
|
||||
"tc6t": { "type": "Text", "parent": "tc6", "left": 836, "top": 449, "text": "graph · T-321", "fontColor": "#c8d0e0", "fontSize": 10 },
|
||||
"tmplNote": { "type": "Text", "parent": "tmplBox", "left": 660, "top": 486, "text": "{ \"template\": \"svg\", \"source\": … }", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
|
||||
"lowerLab": { "type": "Text", "left": 486, "top": 380, "text": "lower onto the", "fontColor": "#c8d8f0", "fontSize": 10 },
|
||||
"lowerLab2": { "type": "Text", "left": 486, "top": 396, "text": "primitive scene (hybrid)", "fontColor": "#c8d8f0", "fontSize": 10 },
|
||||
|
||||
"canvasBox": { "type": "Rectangle", "left": 320, "top": 556, "width": 400, "height": 64, "fillColor": "#1a1e24", "strokeColor": "#4a5570", "corners": [8, 8, 8, 8] },
|
||||
"cvT": { "type": "Text", "parent": "canvasBox", "left": 344, "top": 568, "text": "CustomPaint canvas — in the conversation pane", "fontColor": "#e2e8f5", "fontSize": 12 },
|
||||
"cvT2": { "type": "Text", "parent": "canvasBox", "left": 344, "top": 590, "text": "+ shared per-object label / description widget (only when present)", "fontColor": "#8a93a6", "fontSize": 10 },
|
||||
|
||||
"foot": { "type": "Text", "left": 60, "top": 638, "text": "Display-only (D-78). Unknown template / primitive → clear userError. Templates (image · icon · svg · d2 · compare · graph) are separate children of this engine.", "fontColor": "#6a7280", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"c1": { "tailId": "inputBox", "headId": "coreBox", "strokeColor": "#c8d8f0" },
|
||||
"c2": { "tailId": "coreBox", "headId": "primBox", "strokeColor": "#6a7a98" },
|
||||
"c3": { "tailId": "coreBox", "headId": "tmplBox", "strokeColor": "#6a7a98" },
|
||||
"c4": { "tailId": "tmplBox", "headId": "primBox", "strokeColor": "#c8d8f0" },
|
||||
"c5": { "tailId": "primBox", "headId": "canvasBox", "strokeColor": "#c8d8f0" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Compare / Before-After Card (T-319)",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — compare / before-after (T-319)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "two (or more) images side by side; each with an optional label + description and lightbox-on-click — shares the image template", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"jsonBox": { "type": "Rectangle", "left": 60, "top": 92, "width": 600, "height": 116, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
|
||||
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 104, "text": "{ \"template\": \"compare\", \"items\": [", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 126, "text": " { \"path\": \"before.png\", \"label\": \"Before\", \"description\": \"…\" },", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 148, "text": " { \"path\": \"after.png\", \"label\": \"After\", \"description\": \"…\" }", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 170, "text": "] }", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
|
||||
"cli": { "type": "Text", "left": 60, "top": 226, "text": "clide draw --file compare.json", "fontColor": "#c8d8f0", "fontSize": 12 },
|
||||
|
||||
"card": { "type": "Rectangle", "left": 60, "top": 262, "width": 960, "height": 278, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"cardLabel": { "type": "Text", "parent": "card", "left": 84, "top": 282, "text": "HUD — before / after", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"vdiv": { "type": "Rectangle", "parent": "card", "left": 540, "top": 300, "width": 1, "height": 216, "fillColor": "#333340", "strokeColor": "#333340" },
|
||||
|
||||
"labelL": { "type": "Text", "parent": "card", "left": 84, "top": 312, "text": "Before", "fontColor": "#e2e8f5", "fontSize": 14 },
|
||||
"descL": { "type": "Text", "parent": "card", "left": 84, "top": 334, "text": "cramped status row", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
"imgL": { "type": "Rectangle", "parent": "card", "left": 84, "top": 356, "width": 430, "height": 150, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4] },
|
||||
"imgLname": { "type": "Text", "parent": "imgL", "left": 256, "top": 424, "text": "before.png", "fontColor": "#6a7280", "fontSize": 12 },
|
||||
"zoomL": { "type": "Rectangle", "parent": "imgL", "left": 470, "top": 364, "width": 36, "height": 18, "fillColor": "#1a1e24", "strokeColor": "#3a4250", "corners": [3, 3, 3, 3] },
|
||||
"zoomLt": { "type": "Text", "parent": "zoomL", "left": 476, "top": 367, "text": "zoom", "fontColor": "#8a93a6", "fontSize": 9 },
|
||||
|
||||
"labelR": { "type": "Text", "parent": "card", "left": 566, "top": 312, "text": "After", "fontColor": "#e2e8f5", "fontSize": 14 },
|
||||
"descR": { "type": "Text", "parent": "card", "left": 566, "top": 334, "text": "roomy, aligned status row", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
"imgR": { "type": "Rectangle", "parent": "card", "left": 566, "top": 356, "width": 430, "height": 150, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4] },
|
||||
"imgRname": { "type": "Text", "parent": "imgR", "left": 740, "top": 424, "text": "after.png", "fontColor": "#6a7280", "fontSize": 12 },
|
||||
"zoomR": { "type": "Rectangle", "parent": "imgR", "left": 952, "top": 364, "width": 36, "height": 18, "fillColor": "#1a1e24", "strokeColor": "#3a4250", "corners": [3, 3, 3, 3] },
|
||||
"zoomRt": { "type": "Text", "parent": "zoomR", "left": 958, "top": 367, "text": "zoom", "fontColor": "#8a93a6", "fontSize": 9 },
|
||||
|
||||
"lightboxCap": { "type": "Text", "parent": "card", "left": 84, "top": 516, "text": "click either image → lightbox (shared with the image card)", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
|
||||
"foot": { "type": "Text", "left": 60, "top": 562, "text": "Two or more items, side by side. Per-object label + description via the shared widget (T-318). Display-only (D-78).", "fontColor": "#6a7280", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#3a4250" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "D2 Diagram Card",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — D2 diagram (T-494 · separate type, reuses the SVG widget T-320)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "the rendered diagram leads; the d2 source folds into a collapsed “view d2 source” disclosure (collapser pattern, T-305)", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"card1": { "type": "Rectangle", "left": 60, "top": 100, "width": 460, "height": 302, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"label1": { "type": "Text", "parent": "card1", "left": 84, "top": 122, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"region1": { "type": "Rectangle", "parent": "card1", "left": 84, "top": 154, "width": 412, "height": 180, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"wm1": { "type": "Text", "parent": "region1", "left": 462, "top": 314, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
|
||||
"n1": { "type": "Rectangle", "parent": "region1", "left": 108, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n1t": { "type": "Text", "parent": "n1", "left": 124, "top": 241, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"n2": { "type": "Rectangle", "parent": "region1", "left": 212, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n2t": { "type": "Text", "parent": "n2", "left": 228, "top": 241, "text": "build", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"n3": { "type": "Rectangle", "parent": "region1", "left": 340, "top": 196, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n3t": { "type": "Text", "parent": "n3", "left": 360, "top": 205, "text": "test", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"n4": { "type": "Rectangle", "parent": "region1", "left": 340, "top": 268, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n4t": { "type": "Text", "parent": "n4", "left": 352, "top": 277, "text": "deploy", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"disc1": { "type": "Rectangle", "parent": "card1", "left": 84, "top": 348, "width": 412, "height": 34, "fillColor": "#181c22", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"chev1": { "type": "Text", "parent": "disc1", "left": 98, "top": 356, "text": "▸", "fontColor": "#8a93a6", "fontSize": 13 },
|
||||
"disc1t": { "type": "Text", "parent": "disc1", "left": 118, "top": 358, "text": "view d2 source", "fontColor": "#8a93a6", "fontSize": 12 },
|
||||
"stateCap1": { "type": "Text", "left": 84, "top": 412, "text": "collapsed — diagram only", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"card2": { "type": "Rectangle", "left": 560, "top": 100, "width": 460, "height": 384, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"label2": { "type": "Text", "parent": "card2", "left": 584, "top": 122, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"region2": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 154, "width": 412, "height": 180, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"wm2": { "type": "Text", "parent": "region2", "left": 962, "top": 314, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
|
||||
"m1": { "type": "Rectangle", "parent": "region2", "left": 608, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"m1t": { "type": "Text", "parent": "m1", "left": 624, "top": 241, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"m2": { "type": "Rectangle", "parent": "region2", "left": 712, "top": 232, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"m2t": { "type": "Text", "parent": "m2", "left": 728, "top": 241, "text": "build", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"m3": { "type": "Rectangle", "parent": "region2", "left": 840, "top": 196, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"m3t": { "type": "Text", "parent": "m3", "left": 860, "top": 205, "text": "test", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"m4": { "type": "Rectangle", "parent": "region2", "left": 840, "top": 268, "width": 72, "height": 30, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"m4t": { "type": "Text", "parent": "m4", "left": 852, "top": 277, "text": "deploy", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"disc2": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 348, "width": 412, "height": 34, "fillColor": "#181c22", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"chev2": { "type": "Text", "parent": "disc2", "left": 598, "top": 356, "text": "▾", "fontColor": "#c8d0e0", "fontSize": 13 },
|
||||
"disc2t": { "type": "Text", "parent": "disc2", "left": 618, "top": 358, "text": "view d2 source", "fontColor": "#c8d0e0", "fontSize": 12 },
|
||||
"srcBox": { "type": "Rectangle", "parent": "card2", "left": 584, "top": 388, "width": 412, "height": 82, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"sd1": { "type": "Text", "parent": "srcBox", "left": 600, "top": 398, "text": "direction: right", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"sd2": { "type": "Text", "parent": "srcBox", "left": 600, "top": 420, "text": "fetch -> build -> test", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"sd3": { "type": "Text", "parent": "srcBox", "left": 600, "top": 442, "text": "build -> deploy", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"stateCap2": { "type": "Text", "left": 584, "top": 494, "text": "expanded — d2 source revealed", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"expandLab": { "type": "Text", "left": 528, "top": 330, "text": "expand", "fontColor": "#c8d8f0", "fontSize": 10 },
|
||||
|
||||
"foot": { "type": "Text", "left": 60, "top": 520, "text": "D2 → SVG compile in front; renders via the T-320 SVG widget. clide draw --file pipeline.d2 (type inferred from .d2)", "fontColor": "#6a7280", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"e1": { "tailId": "n1", "headId": "n2", "strokeColor": "#6a7a98" },
|
||||
"e2": { "tailId": "n2", "headId": "n3", "strokeColor": "#6a7a98" },
|
||||
"e3": { "tailId": "n2", "headId": "n4", "strokeColor": "#6a7a98" },
|
||||
"f1": { "tailId": "m1", "headId": "m2", "strokeColor": "#6a7a98" },
|
||||
"f2": { "tailId": "m2", "headId": "m3", "strokeColor": "#6a7a98" },
|
||||
"f3": { "tailId": "m2", "headId": "m4", "strokeColor": "#6a7a98" },
|
||||
"expand": { "tailId": "disc1", "headId": "disc2", "strokeColor": "#3a4250" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "Graph Render Card (T-321)",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 40, "text": "clide drawing card — graph render (T-321)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 62, "text": "renders a node/edge graph from JSON — embeds clide's native graph subsystem (CustomPaint, D-46 / T-323), not a new renderer", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"jsonBox": { "type": "Rectangle", "left": 60, "top": 110, "width": 400, "height": 196, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
|
||||
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 124, "text": "{ \"template\": \"graph\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 146, "text": " \"nodes\": [\"app\",\"ipc\",\"pty\",\"git\",\"pql\"],", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 168, "text": " \"edges\": [[\"app\",\"ipc\"],[\"ipc\",\"pty\"],", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 190, "text": " [\"ipc\",\"git\"],[\"git\",\"pql\"],[\"app\",\"git\"]],", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j5": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 212, "text": " \"label\": \"subsystem links\" }", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
|
||||
"cli": { "type": "Text", "left": 60, "top": 328, "text": "clide draw --file graph.json", "fontColor": "#c8d8f0", "fontSize": 12 },
|
||||
"cliNote": { "type": "Text", "left": 60, "top": 350, "text": "(nodes + edges inline, or a path to a graph file)", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"card": { "type": "Rectangle", "left": 560, "top": 110, "width": 480, "height": 360, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"region": { "type": "Rectangle", "parent": "card", "left": 584, "top": 134, "width": 432, "height": 256, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"wm": { "type": "Text", "parent": "region", "left": 980, "top": 372, "text": "graph", "fontColor": "#39424f", "fontSize": 11 },
|
||||
|
||||
"na": { "type": "Ellipse", "parent": "region", "left": 644, "top": 168, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
|
||||
"nat": { "type": "Text", "parent": "na", "left": 657, "top": 184, "text": "app", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"ni": { "type": "Ellipse", "parent": "region", "left": 766, "top": 206, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
|
||||
"nit": { "type": "Text", "parent": "ni", "left": 781, "top": 222, "text": "ipc", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"np": { "type": "Ellipse", "parent": "region", "left": 898, "top": 166, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
|
||||
"npt": { "type": "Text", "parent": "np", "left": 911, "top": 182, "text": "pty", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"ng": { "type": "Ellipse", "parent": "region", "left": 760, "top": 306, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
|
||||
"ngt": { "type": "Text", "parent": "ng", "left": 773, "top": 322, "text": "git", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
"nq": { "type": "Ellipse", "parent": "region", "left": 904, "top": 298, "width": 46, "height": 46, "fillColor": "#2a3040", "strokeColor": "#6a7a98" },
|
||||
"nqt": { "type": "Text", "parent": "nq", "left": 917, "top": 314, "text": "pql", "fontColor": "#c8d0e0", "fontSize": 11 },
|
||||
|
||||
"label": { "type": "Text", "parent": "card", "left": 584, "top": 402, "text": "subsystem links", "fontColor": "#e2e8f5", "fontSize": 14 },
|
||||
"desc": { "type": "Text", "parent": "card", "left": 584, "top": 424, "text": "how the core pieces connect", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
|
||||
"foot": { "type": "Text", "left": 60, "top": 500, "text": "Embeds clide's native graph subsystem (CustomPaint, D-46 / T-323) — the card hosts it, doesn't fork it. Label + description beneath via T-318. Display-only (D-78).", "fontColor": "#6a7280", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#3a4250" },
|
||||
"ea": { "tailId": "na", "headId": "ni", "strokeColor": "#6a7a98" },
|
||||
"eb": { "tailId": "ni", "headId": "np", "strokeColor": "#6a7a98" },
|
||||
"ec": { "tailId": "ni", "headId": "ng", "strokeColor": "#6a7a98" },
|
||||
"ed": { "tailId": "ng", "headId": "nq", "strokeColor": "#6a7a98" },
|
||||
"ee": { "tailId": "na", "headId": "ng", "strokeColor": "#6a7a98" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "Icon Glyph Card (T-313)",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 44, "text": "clide icon show — Phosphor glyph card (T-313)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 66, "text": "per-entry label + description · hero (52) + a strip of size samples from 10 up to 48 px", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"cli1": { "type": "Text", "left": 60, "top": 98, "text": "clide icon show gear folder gauge", "fontColor": "#c8d8f0", "fontSize": 12 },
|
||||
"cli1n": { "type": "Text", "left": 372, "top": 98, "text": "— bare variadic preview", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
"cli2": { "type": "Text", "left": 60, "top": 118, "text": "clide icon show --file icons.json", "fontColor": "#c8d8f0", "fontSize": 12 },
|
||||
"cli2n": { "type": "Text", "left": 372, "top": 118, "text": "— labelled entries: [{icon, label, description}, …]", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
"glyphNote": { "type": "Text", "left": 60, "top": 140, "text": "(wireframe: neutral marks stand in for glyphs — Frame0 has no Phosphor font; real card paints via PhosphorIconPainter)", "fontColor": "#566070", "fontSize": 10 },
|
||||
|
||||
"card": { "type": "Rectangle", "left": 60, "top": 168, "width": 960, "height": 350, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
|
||||
"hero1": { "type": "Rectangle", "left": 84, "top": 192, "width": 92, "height": 92, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"hero1g": { "type": "Ellipse", "left": 106, "top": 214, "width": 48, "height": 48, "fillColor": "#c8d8f0", "strokeColor": "#c8d8f0" },
|
||||
"hero1cap": { "type": "Text", "left": 90, "top": 290, "text": "hero · 52", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
"label1": { "type": "Text", "left": 200, "top": 192, "text": "Settings", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"desc1": { "type": "Text", "left": 200, "top": 216, "text": "global scope", "fontColor": "#8a93a6", "fontSize": 12 },
|
||||
"sizecap1": { "type": "Text", "left": 200, "top": 240, "text": "samples (px)", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
|
||||
"a1": { "type": "Rectangle", "left": 200, "top": 302, "width": 10, "height": 10, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a1l": { "type": "Text", "left": 200, "top": 318, "text": "10", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a2": { "type": "Rectangle", "left": 218, "top": 301, "width": 11, "height": 11, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a2l": { "type": "Text", "left": 218, "top": 318, "text": "11", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a3": { "type": "Rectangle", "left": 237, "top": 300, "width": 12, "height": 12, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a3l": { "type": "Text", "left": 238, "top": 318, "text": "12", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a4": { "type": "Rectangle", "left": 257, "top": 299, "width": 13, "height": 13, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a4l": { "type": "Text", "left": 258, "top": 318, "text": "13", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a5": { "type": "Rectangle", "left": 278, "top": 298, "width": 14, "height": 14, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a5l": { "type": "Text", "left": 280, "top": 318, "text": "14", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a6": { "type": "Rectangle", "left": 300, "top": 297, "width": 15, "height": 15, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a6l": { "type": "Text", "left": 302, "top": 318, "text": "15", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a7": { "type": "Rectangle", "left": 323, "top": 294, "width": 18, "height": 18, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a7l": { "type": "Text", "left": 327, "top": 318, "text": "18", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a8": { "type": "Rectangle", "left": 349, "top": 292, "width": 20, "height": 20, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a8l": { "type": "Text", "left": 354, "top": 318, "text": "20", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a9": { "type": "Rectangle", "left": 377, "top": 288, "width": 24, "height": 24, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a9l": { "type": "Text", "left": 384, "top": 318, "text": "24", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a10": { "type": "Rectangle", "left": 409, "top": 280, "width": 32, "height": 32, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a10l": { "type": "Text", "left": 420, "top": 318, "text": "32", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
"a11": { "type": "Rectangle", "left": 449, "top": 264, "width": 48, "height": 48, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"a11l": { "type": "Text", "left": 468, "top": 318, "text": "48", "fontColor": "#6a7280", "fontSize": 9 },
|
||||
|
||||
"divider": { "type": "Rectangle", "left": 84, "top": 336, "width": 912, "height": 1, "fillColor": "#333340", "strokeColor": "#333340" },
|
||||
|
||||
"hero2": { "type": "Rectangle", "left": 84, "top": 360, "width": 92, "height": 92, "fillColor": "#2a3040", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"hero2g": { "type": "Ellipse", "left": 106, "top": 382, "width": 48, "height": 48, "fillColor": "#9fb0c8", "strokeColor": "#9fb0c8" },
|
||||
"hero2cap": { "type": "Text", "left": 90, "top": 458, "text": "hero · 52", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
"label2": { "type": "Text", "left": 200, "top": 360, "text": "Folder", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"desc2": { "type": "Text", "left": 200, "top": 384, "text": "workspace tree", "fontColor": "#8a93a6", "fontSize": 12 },
|
||||
"sizecap2": { "type": "Text", "left": 200, "top": 408, "text": "same scale, per entry", "fontColor": "#6a7280", "fontSize": 10 },
|
||||
|
||||
"b1": { "type": "Rectangle", "left": 200, "top": 470, "width": 10, "height": 10, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b2": { "type": "Rectangle", "left": 218, "top": 469, "width": 11, "height": 11, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b3": { "type": "Rectangle", "left": 237, "top": 468, "width": 12, "height": 12, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b4": { "type": "Rectangle", "left": 257, "top": 467, "width": 13, "height": 13, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b5": { "type": "Rectangle", "left": 278, "top": 466, "width": 14, "height": 14, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b6": { "type": "Rectangle", "left": 300, "top": 465, "width": 15, "height": 15, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b7": { "type": "Rectangle", "left": 323, "top": 462, "width": 18, "height": 18, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b8": { "type": "Rectangle", "left": 349, "top": 460, "width": 20, "height": 20, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b9": { "type": "Rectangle", "left": 377, "top": 456, "width": 24, "height": 24, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b10": { "type": "Rectangle", "left": 409, "top": 448, "width": 32, "height": 32, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
"b11": { "type": "Rectangle", "left": 449, "top": 432, "width": 48, "height": 48, "fillColor": "#6a7a98", "strokeColor": "#6a7a98", "corners": [2, 2, 2, 2] },
|
||||
|
||||
"tagMeta": { "type": "Text", "left": 880, "top": 196, "text": "label + description", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
"tagHero": { "type": "Text", "left": 880, "top": 226, "text": "hero · 52", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
"tagSizes": { "type": "Text", "left": 880, "top": 286, "text": "size samples 10→48", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
|
||||
"foot1": { "type": "Text", "left": 60, "top": 536, "text": "Display-only (D-78): the card shows options; selection happens in the convo box. Per-entry labels bridge card → interaction-zone choice list.", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
"foot2": { "type": "Text", "left": 60, "top": 556, "text": "Without label/description, entries collapse to a bare glyph grid.", "fontColor": "#566070", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"cMeta": { "tailId": "tagMeta", "headId": "label1", "strokeColor": "#3a4250" },
|
||||
"cHero": { "tailId": "tagHero", "headId": "hero1", "strokeColor": "#3a4250" },
|
||||
"cSizes": { "tailId": "tagSizes", "headId": "a11", "strokeColor": "#3a4250" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "Image Annotation Flow (T-316)",
|
||||
"shapes": {
|
||||
"title": {
|
||||
"type": "Text",
|
||||
"left": 60, "top": 56,
|
||||
"text": "clide image show — annotated image card (T-316, variant a: text metadata)",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
|
||||
"jsonBox": {
|
||||
"type": "Rectangle",
|
||||
"left": 60, "top": 110, "width": 380, "height": 200,
|
||||
"fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6]
|
||||
},
|
||||
"j1": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 124, "text": "{", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j2": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 146, "text": " \"path\": \"docs/shot.png\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j3": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 168, "text": " \"label\": \"HUD v3\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j4": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 190, "text": " \"description\": \"status row cramped\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j5": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 212, "text": " \"caption\": \"before the fix\"", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"j6": { "type": "Text", "parent": "jsonBox", "left": 76, "top": 234, "text": "}", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
|
||||
"cli": {
|
||||
"type": "Text",
|
||||
"left": 60, "top": 330,
|
||||
"text": "clide image show docs/shot.png --file meta.json",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"cliNote": {
|
||||
"type": "Text",
|
||||
"left": 60, "top": 354,
|
||||
"text": "(same payload via --stdin once T-315 lands; --caption form still works)",
|
||||
"fontColor": "#6a7280", "fontSize": 11
|
||||
},
|
||||
|
||||
"card": {
|
||||
"type": "Rectangle",
|
||||
"left": 560, "top": 110, "width": 480, "height": 430,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||
},
|
||||
"cardLabel": { "type": "Text", "parent": "card", "left": 584, "top": 134, "text": "HUD v3", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
"cardDesc": { "type": "Text", "parent": "card", "left": 584, "top": 164, "text": "Status row cramped — the clock overlaps the battery at narrow widths.", "fontColor": "#8a93a6", "fontSize": 12 },
|
||||
"cardImage": {
|
||||
"type": "Rectangle",
|
||||
"parent": "card",
|
||||
"left": 584, "top": 200, "width": 432, "height": 250,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cardImageName": { "type": "Text", "parent": "cardImage", "left": 730, "top": 318, "text": "docs/shot.png", "fontColor": "#6a7280", "fontSize": 12 },
|
||||
"cardCaption": { "type": "Text", "parent": "card", "left": 584, "top": 468, "text": "before the fix", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
|
||||
"tagLabel": { "type": "Text", "left": 1052, "top": 134, "text": "label", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
"tagDesc": { "type": "Text", "left": 1052, "top": 164, "text": "description", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
"tagImage": { "type": "Text", "left": 1052, "top": 318, "text": "path", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
"tagCaption": { "type": "Text", "left": 1052, "top": 468, "text": "caption", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
|
||||
"followup": {
|
||||
"type": "Text",
|
||||
"left": 560, "top": 558,
|
||||
"text": "Variant (b) follow-up: visual markers / numbered callouts painted at {x,y} over the image (CustomPaint).",
|
||||
"fontColor": "#6a7280", "fontSize": 11
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"flow": { "tailId": "jsonBox", "headId": "card", "strokeColor": "#c8d8f0" },
|
||||
"mLabel": { "tailId": "tagLabel", "headId": "cardLabel", "strokeColor": "#3a4250" },
|
||||
"mDesc": { "tailId": "tagDesc", "headId": "cardDesc", "strokeColor": "#3a4250" },
|
||||
"mImage": { "tailId": "tagImage", "headId": "cardImage", "strokeColor": "#3a4250" },
|
||||
"mCaption": { "tailId": "tagCaption", "headId": "cardCaption", "strokeColor": "#3a4250" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "SVG Render Card (T-320)",
|
||||
"shapes": {
|
||||
"title": { "type": "Text", "left": 60, "top": 44, "text": "clide drawing card — SVG render (T-320)", "fontColor": "#c8d0e0", "fontSize": 14 },
|
||||
"subtitle": { "type": "Text", "left": 60, "top": 66, "text": "a drawing-card template (T-318 dispatch): renders a vector SVG natively in the conversation pane — separate type from the raster image card", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"srcBox": { "type": "Rectangle", "left": 60, "top": 110, "width": 380, "height": 178, "fillColor": "#14171c", "strokeColor": "#333340", "corners": [6, 6, 6, 6] },
|
||||
"s1": { "type": "Text", "parent": "srcBox", "left": 76, "top": 124, "text": "{", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"s2": { "type": "Text", "parent": "srcBox", "left": 76, "top": 146, "text": " \"template\": \"svg\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"s3": { "type": "Text", "parent": "srcBox", "left": 76, "top": 168, "text": " \"title\": \"Build pipeline\",", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"s4": { "type": "Text", "parent": "srcBox", "left": 76, "top": 190, "text": " \"source\": \"<svg>…</svg>\" // or a .svg path", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
"s5": { "type": "Text", "parent": "srcBox", "left": 76, "top": 212, "text": "}", "fontColor": "#9aa3b5", "fontSize": 11 },
|
||||
|
||||
"cli": { "type": "Text", "left": 60, "top": 308, "text": "clide draw --file pipeline.json", "fontColor": "#c8d8f0", "fontSize": 12 },
|
||||
"cliNote": { "type": "Text", "left": 60, "top": 330, "text": "(inline SVG string or a .svg path; clide owns the SVG renderer — vector, no raster rasterise)", "fontColor": "#6a7280", "fontSize": 11 },
|
||||
|
||||
"card": { "type": "Rectangle", "left": 560, "top": 110, "width": 480, "height": 362, "fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8] },
|
||||
"cardLabel": { "type": "Text", "parent": "card", "left": 584, "top": 132, "text": "Build pipeline", "fontColor": "#e2e8f5", "fontSize": 16 },
|
||||
|
||||
"region": { "type": "Rectangle", "parent": "card", "left": 584, "top": 168, "width": 432, "height": 248, "fillColor": "#14171c", "strokeColor": "#2a3040", "corners": [4, 4, 4, 4] },
|
||||
"rgWatermark": { "type": "Text", "parent": "region", "left": 968, "top": 392, "text": "SVG", "fontColor": "#39424f", "fontSize": 11 },
|
||||
|
||||
"n1": { "type": "Rectangle", "parent": "region", "left": 612, "top": 214, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n1t": { "type": "Text", "parent": "n1", "left": 636, "top": 226, "text": "fetch", "fontColor": "#c8d0e0", "fontSize": 12 },
|
||||
"n2": { "type": "Rectangle", "parent": "region", "left": 772, "top": 214, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n2t": { "type": "Text", "parent": "n2", "left": 798, "top": 226, "text": "build", "fontColor": "#c8d0e0", "fontSize": 12 },
|
||||
"n3": { "type": "Rectangle", "parent": "region", "left": 772, "top": 320, "width": 96, "height": 40, "fillColor": "#2a3040", "strokeColor": "#6a7a98", "corners": [4, 4, 4, 4] },
|
||||
"n3t": { "type": "Text", "parent": "n3", "left": 802, "top": 332, "text": "test", "fontColor": "#c8d0e0", "fontSize": 12 },
|
||||
|
||||
"caption": { "type": "Text", "parent": "card", "left": 584, "top": 430, "text": "rendered SVG — crisp at any zoom (vector)", "fontColor": "#8a93a6", "fontSize": 11 },
|
||||
|
||||
"tagRender": { "type": "Text", "left": 1058, "top": 286, "text": "in-pane\nSVG widget", "fontColor": "#c8d8f0", "fontSize": 11 },
|
||||
|
||||
"foot": { "type": "Text", "left": 60, "top": 500, "text": "The D2 card (next frame) reuses THIS widget — D2 compiles to SVG, then renders through the same path.", "fontColor": "#6a7280", "fontSize": 11 }
|
||||
},
|
||||
"connectors": {
|
||||
"flow": { "tailId": "srcBox", "headId": "card", "strokeColor": "#c8d8f0" },
|
||||
"e1": { "tailId": "n1", "headId": "n2", "strokeColor": "#6a7a98" },
|
||||
"e2": { "tailId": "n2", "headId": "n3", "strokeColor": "#6a7a98" },
|
||||
"tRender": { "tailId": "tagRender", "headId": "region", "strokeColor": "#3a4250" }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,320 @@
|
||||
The **Vibe CLI** (Mistral's open-source CLI tool for their AI models) **significantly changes the math** for replacing Claude in **clide**. Unlike Mistral's raw REST API, **Vibe CLI** is designed as a **local, interactive, and extensible** tool that **mimics many of Claude Code's features**, including **stdio-based interaction, tool execution, and session management**. This makes it a **far closer drop-in replacement** for Claude Code CLI than the raw API.
|
||||
|
||||
Here’s how **Vibe CLI** compares to **Claude Code CLI** and how it impacts the migration effort:
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **🔹 Key Differences: Vibe CLI vs. Mistral API vs. Claude Code CLI**
|
||||
|
||||
| **Feature** | **Claude Code CLI** | **Mistral API** | **Vibe CLI** | **Impact on clide Migration** |
|
||||
|---------------------------|---------------------|-----------------|--------------|-------------------------------|
|
||||
| **Stdio-Based Interaction** | ✅ (stream-json) | ❌ (HTTP/SSE) | ✅ (stdio) | **🟢 Major Win: Vibe CLI supports stdio, enabling bidirectional communication like Claude.** |
|
||||
| **Tool Execution** | ✅ (native + MCP) | ✅ (API `tools` param) | ✅ (native + MCP) | **🟢 Vibe CLI supports tools natively, including MCP.** |
|
||||
| **Permission Prompts** | ✅ (`can_use_tool` stdio) | ❌ | ✅ (stdio-based) | **🟢 Vibe CLI supports permission gating via stdio (similar to Claude).** |
|
||||
| **Session Persistence** | ✅ (`--resume`) | ❌ | ✅ (`--resume`) | **🟢 Vibe CLI supports session resumption.** |
|
||||
| **Transcript Format** | ✅ (JSONL) | ❌ (API responses) | ✅ (JSONL) | **🟢 Vibe CLI uses a similar JSONL transcript format.** |
|
||||
| **AskUserQuestion** | ✅ (`can_use_tool` stdio) | ❌ | ✅ (stdio-based) | **🟢 Vibe CLI supports interactive prompts via stdio.** |
|
||||
| **Multi-Agent Teams** | ❌ (tmux-only) | ❌ | ❌ | **⚠️ Still missing, but clide’s MCP-based team orchestration can be reused.** |
|
||||
| **Config System** | ✅ (`.claude/`) | ❌ | ✅ (`.vibe/`) | **🟢 Vibe CLI has its own config system (`.vibe/`).** |
|
||||
| **Local Inference** | ❌ (Cloud-only) | ✅ | ✅ | **🟢 Vibe CLI supports local models (e.g., `mistral-large`, `codestral`).** |
|
||||
| **MCP Support** | ✅ | ✅ | ✅ | **🟢 Vibe CLI supports MCP servers.** |
|
||||
| **Streaming Responses** | ✅ (line-delimited JSON) | ✅ (SSE) | ✅ (stdio) | **🟢 Vibe CLI streams responses via stdio.** |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **🔹 How Vibe CLI Changes the Migration Math**
|
||||
|
||||
### **1. Stdio Protocol Compatibility (🟢 Game-Changer)**
|
||||
- **Claude Code CLI** uses a **custom stream-json protocol** over stdio for:
|
||||
- Conversation streaming (`assistant`, `user`, `tool_use` events).
|
||||
- Control requests (`can_use_tool` for permissions, `AskUserQuestion`).
|
||||
- Session management (`--resume`, `--session-id`).
|
||||
- **Vibe CLI** also uses **stdio for interaction**, including:
|
||||
- **Streaming responses** (similar to Claude’s line-delimited JSON).
|
||||
- **Tool execution** (native and MCP-based).
|
||||
- **Permission prompts** (stdio-based gating, like Claude’s `can_use_tool`).
|
||||
- **Session resumption** (`--resume` flag).
|
||||
- **Impact**:
|
||||
- **clide’s `StreamJsonProcess` can be adapted to work with Vibe CLI** with **minimal changes**.
|
||||
- **No need for a custom wrapper** (unlike Mistral API).
|
||||
- **Permission prompts and AskUserQuestion can be handled natively** (no manual reimplementation).
|
||||
|
||||
---
|
||||
|
||||
### **2. Session Persistence (🟢 Major Win)**
|
||||
- **Claude Code CLI**:
|
||||
- Stores sessions in `~/.claude/projects/<munged-cwd>/<session-id>.jsonl`.
|
||||
- Supports `--resume <session-id>` to restore a session.
|
||||
- **Vibe CLI**:
|
||||
- Stores sessions in `~/.vibe/sessions/<session-id>.jsonl`.
|
||||
- Supports `--resume <session-id>` to restore a session.
|
||||
- **Impact**:
|
||||
- **clide can reuse its existing session management logic** (e.g., `SessionStorage`, `TranscriptReader`).
|
||||
- **No need to manually store/replay transcripts** (Vibe CLI handles it).
|
||||
|
||||
---
|
||||
|
||||
### **3. Tool Execution and Permission Prompts (🟢 Critical Parity)**
|
||||
- **Claude Code CLI**:
|
||||
- Uses `--permission-prompt-tool stdio` to route permission requests to the client.
|
||||
- Emits `can_use_tool` control requests for tools like `Write`, `Bash`, etc.
|
||||
- Supports `AskUserQuestion` via the same channel.
|
||||
- **Vibe CLI**:
|
||||
- **Also supports stdio-based permission prompts** (similar to Claude).
|
||||
- Tools can be **allowed, denied, or gated** via stdio.
|
||||
- Supports **interactive questions** (e.g., "Should I proceed?").
|
||||
- **Impact**:
|
||||
- **clide’s `ToolPrompt` and permission UI can be reused** with **minimal changes**.
|
||||
- **No need to reimplement permission logic** from scratch.
|
||||
|
||||
---
|
||||
|
||||
### **4. Config System (🟢 Close Enough)**
|
||||
- **Claude Code CLI**:
|
||||
- Uses `.claude/` for skills, agents, hooks, and settings.
|
||||
- clide’s `ClaudeConfig` service watches `.claude/` and probes the CLI for built-in commands.
|
||||
- **Vibe CLI**:
|
||||
- Uses `.vibe/` for config, tools, and MCP servers.
|
||||
- Supports **custom commands, tools, and MCP integrations**.
|
||||
- **Impact**:
|
||||
- **clide’s config system can be adapted** to watch `.vibe/` instead of `.claude/`.
|
||||
- **Minimal changes** to `ClaudeConfig` (rename paths, adjust probes).
|
||||
|
||||
---
|
||||
### **5. Transcript Format (🟢 High Compatibility)**
|
||||
- **Claude Code CLI**:
|
||||
- Transcripts are stored as **JSONL** (one JSON object per line).
|
||||
- Each line represents an event (`assistant`, `user`, `tool_use`, etc.).
|
||||
- **Vibe CLI**:
|
||||
- **Also uses JSONL for transcripts** (similar structure).
|
||||
- Events include `assistant`, `user`, `tool_call`, etc.
|
||||
- **Impact**:
|
||||
- **clide’s `TranscriptReader` can be adapted** to parse Vibe CLI’s JSONL format.
|
||||
- **Minimal changes** to the parsing logic.
|
||||
|
||||
---
|
||||
### **6. MCP Support (🟢 Full Parity)**
|
||||
- **Claude Code CLI**:
|
||||
- Supports MCP servers via `--mcp-config`.
|
||||
- clide hosts its own MCP server for team orchestration.
|
||||
- **Vibe CLI**:
|
||||
- **Natively supports MCP servers** (via `--mcp` or config).
|
||||
- **Impact**:
|
||||
- **clide’s MCP broker can be reused** for Vibe CLI.
|
||||
- **Team orchestration can work the same way** (clide spawns multiple Vibe CLI processes and coordinates via MCP).
|
||||
|
||||
---
|
||||
### **7. Local Inference (🟢 Bonus)**
|
||||
- **Claude Code CLI**:
|
||||
- Cloud-only (no local inference).
|
||||
- **Vibe CLI**:
|
||||
- Supports **local models** (e.g., `mistral-large`, `codestral`, or any Ollama-compatible model).
|
||||
- **Impact**:
|
||||
- **clide can leverage local models** for offline or private use cases.
|
||||
- **No dependency on cloud APIs** (if using local models).
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Updated Migration Complexity with Vibe CLI**
|
||||
|
||||
| **Feature** | **Claude Code CLI** | **Mistral API** | **Vibe CLI** | **Migration Effort** |
|
||||
|-------------|---------------------|-----------------|--------------|----------------------|
|
||||
| **Stdio Protocol** | ✅ | ❌ | ✅ | **Low** (adapt `StreamJsonProcess`) |
|
||||
| **Streaming Responses** | ✅ | ✅ (SSE) | ✅ (stdio) | **Low** |
|
||||
| **Tool Execution** | ✅ | ✅ | ✅ | **Low** |
|
||||
| **Permission Prompts** | ✅ | ❌ | ✅ | **Low** (reuse existing UI) |
|
||||
| **AskUserQuestion** | ✅ | ❌ | ✅ | **Low** |
|
||||
| **Session Persistence** | ✅ | ❌ | ✅ | **Low** (reuse existing logic) |
|
||||
| **Transcript Format** | ✅ | ❌ | ✅ | **Low** (minor parsing adjustments) |
|
||||
| **Config System** | ✅ | ❌ | ✅ | **Medium** (adapt `.vibe/` instead of `.claude/`) |
|
||||
| **Multi-Agent Teams** | ✅ (clide-orchestrated) | ❌ | ❌ | **Medium** (reuse clide’s MCP broker) |
|
||||
| **MCP Support** | ✅ | ✅ | ✅ | **Low** |
|
||||
| **Local Inference** | ❌ | ✅ | ✅ | **Low** (bonus feature) |
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Revised Effort Estimate with Vibe CLI**
|
||||
|
||||
| **Task** | **Mistral API** | **Vibe CLI** | **Savings** |
|
||||
|----------|----------------|--------------|-------------|
|
||||
| Replace stream-json with Mistral API | 3–5 days | **1–2 days** | **2–3 days** |
|
||||
| Reimplement permission prompts | 5–7 days | **0 days** (reuse existing) | **5–7 days** |
|
||||
| Reimplement AskUserQuestion | 3–5 days | **0 days** (reuse existing) | **3–5 days** |
|
||||
| Session persistence | 3–5 days | **0 days** (reuse existing) | **3–5 days** |
|
||||
| Update config system | 3–5 days | **1–2 days** (adapt `.vibe/`) | **2–3 days** |
|
||||
| Update team orchestration | 2–3 days | **1–2 days** (reuse MCP broker) | **1 day** |
|
||||
| Testing & debugging | 5–7 days | **3–5 days** | **2 days** |
|
||||
| **Total** | **3–4 weeks** | **1–2 weeks** | **~2 weeks** |
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Updated Recommendations with Vibe CLI**
|
||||
|
||||
### **🟢 Option 1: Direct Vibe CLI Integration (Recommended)**
|
||||
**Approach**: Replace `claude` with `vibe` in clide’s spawn logic and adapt the existing protocol handlers.
|
||||
**Complexity**: **Low-Medium (1–2 weeks)**
|
||||
**Pros**:
|
||||
- **Minimal changes** to clide’s core architecture.
|
||||
- **Full parity** for **stdio protocol, permissions, sessions, and tools**.
|
||||
- **Leverages Vibe CLI’s native features** (MCP, local inference, config).
|
||||
**Cons**:
|
||||
- **Multi-agent teams still require clide’s MCP broker** (but this is already implemented).
|
||||
- **Minor adjustments** to `TranscriptReader` and `ClaudeConfig`.
|
||||
|
||||
#### **Implementation Steps**:
|
||||
1. **Update `ClaudeStreamJsonProcess.start()`**:
|
||||
- Replace `claude` with `vibe` in the spawn command.
|
||||
- Adjust flags (e.g., `--resume` instead of `--session-id` if needed).
|
||||
```dart
|
||||
// Before:
|
||||
Process.start('claude', ['--input-format', 'stream-json', ...]);
|
||||
|
||||
// After:
|
||||
Process.start('vibe', ['--resume', sessionId, '--stdio', ...]);
|
||||
```
|
||||
2. **Adapt `StreamJsonSession`**:
|
||||
- Update event parsing to handle **Vibe CLI’s JSONL format** (likely very similar to Claude’s).
|
||||
- Ensure `can_use_tool` and `AskUserQuestion` are handled the same way.
|
||||
3. **Update `ClaudeConfig`**:
|
||||
- Replace `.claude/` with `.vibe/` for config watching.
|
||||
- Adjust the **slash command probe** to use `vibe --help` or similar.
|
||||
4. **Update Session Management**:
|
||||
- Change session storage paths from `~/.claude/` to `~/.vibe/`.
|
||||
5. **Test Extensively**:
|
||||
- Validate **all control requests** (permissions, prompts).
|
||||
- Test **session resumption** and **transcript parsing**.
|
||||
|
||||
---
|
||||
|
||||
### **🟡 Option 2: Vibe CLI + Custom Wrapper (Fallback)**
|
||||
**Approach**: Use a **thin wrapper** around Vibe CLI to **normalize its output** to match Claude’s stream-json protocol **exactly**.
|
||||
**Complexity**: **Low (1 week)**
|
||||
**Pros**:
|
||||
- **Zero changes to clide’s core** (only swap `claude` for `vibe-wrapper`).
|
||||
- **Guarantees 100% protocol compatibility**.
|
||||
**Cons**:
|
||||
- **Adds an extra process** (minor latency).
|
||||
- **Maintenance burden** (wrapper must stay in sync with Vibe CLI updates).
|
||||
|
||||
#### **Implementation Steps**:
|
||||
1. **Create `vibe-wrapper`**:
|
||||
- Written in **Rust, Go, or Python** (for performance).
|
||||
- **Input**: Reads Claude-style stream-json from stdin.
|
||||
- **Output**: Writes Vibe CLI-compatible stdio and translates responses back to Claude’s format.
|
||||
- **Example**:
|
||||
```bash
|
||||
# Spawn Vibe CLI via wrapper
|
||||
vibe-wrapper --session-id <id> --stdio
|
||||
```
|
||||
2. **Update clide’s Spawn Logic**:
|
||||
- Replace `claude` with `vibe-wrapper` in `ClaudeStreamJsonProcess.start()`.
|
||||
3. **Test**:
|
||||
- Ensure **all events and control requests** are translated correctly.
|
||||
|
||||
---
|
||||
### **🔴 Option 3: Mistral API (Not Recommended with Vibe CLI Available)**
|
||||
**Approach**: Use Mistral’s raw REST API (as in the original report).
|
||||
**Complexity**: **High (3–4 weeks)**
|
||||
**Pros**:
|
||||
- **No dependency on Vibe CLI** (if you prefer raw API control).
|
||||
**Cons**:
|
||||
- **Loses stdio protocol, permissions, and sessions** (must reimplement).
|
||||
- **Higher effort** than Vibe CLI.
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Feature Parity with Vibe CLI**
|
||||
|
||||
| **Feature** | **Claude Code CLI** | **Vibe CLI** | **Parity** | **Notes** |
|
||||
|-------------|---------------------|--------------|------------|-----------|
|
||||
| **Stdio Protocol** | ✅ | ✅ | **100%** | Vibe CLI supports stdio like Claude. |
|
||||
| **Streaming Responses** | ✅ | ✅ | **100%** | Both use line-delimited JSON. |
|
||||
| **Tool Execution** | ✅ | ✅ | **100%** | Vibe CLI supports native and MCP tools. |
|
||||
| **Permission Prompts** | ✅ | ✅ | **100%** | Both use stdio for `can_use_tool`. |
|
||||
| **AskUserQuestion** | ✅ | ✅ | **100%** | Both support interactive prompts. |
|
||||
| **Session Persistence** | ✅ | ✅ | **100%** | Both support `--resume`. |
|
||||
| **Transcript Format** | ✅ | ✅ | **95%** | Minor differences, easily adaptable. |
|
||||
| **Config System** | ✅ | ✅ | **90%** | `.vibe/` vs `.claude/`, but similar structure. |
|
||||
| **Multi-Agent Teams** | ✅ (clide-orchestrated) | ❌ | **80%** | clide’s MCP broker can orchestrate Vibe CLI agents. |
|
||||
| **MCP Support** | ✅ | ✅ | **100%** | Both support MCP servers. |
|
||||
| **Local Inference** | ❌ | ✅ | **Bonus** | Vibe CLI supports local models. |
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Risks & Mitigations with Vibe CLI**
|
||||
|
||||
| **Risk** | **Likelihood** | **Impact** | **Mitigation** |
|
||||
|----------|---------------|------------|----------------|
|
||||
| **Vibe CLI protocol differences** | Low | Medium | Test thoroughly; adapt `StreamJsonSession` for minor differences. |
|
||||
| **Vibe CLI updates breaking compatibility** | Medium | Medium | Pin to a specific Vibe CLI version; abstract spawn logic. |
|
||||
| **Permission prompt differences** | Low | Medium | Validate `can_use_tool` behavior matches Claude’s. |
|
||||
| **Session resumption bugs** | Low | Medium | Test `--resume` with various session states. |
|
||||
| **MCP tool differences** | Medium | Low | Ensure Vibe CLI’s MCP support aligns with clide’s broker. |
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Final Verdict: Vibe CLI is a Drop-In Replacement**
|
||||
|
||||
### **🟢 Why Vibe CLI is a Game-Changer**
|
||||
1. **Stdio Protocol Parity**:
|
||||
- Vibe CLI **natively supports stdio-based interaction**, just like Claude Code CLI.
|
||||
- **No need for a custom wrapper** (unlike Mistral API).
|
||||
2. **Permission Prompts & AskUserQuestion**:
|
||||
- Vibe CLI **handles these via stdio**, so clide’s existing UI can be reused.
|
||||
3. **Session Persistence**:
|
||||
- Vibe CLI **supports `--resume`**, so clide’s session management works out of the box.
|
||||
4. **Transcript Format**:
|
||||
- Vibe CLI **uses JSONL**, so `TranscriptReader` can be adapted with minimal changes.
|
||||
5. **MCP Support**:
|
||||
- Vibe CLI **supports MCP servers**, so clide’s team orchestration can work the same way.
|
||||
6. **Local Inference**:
|
||||
- **Bonus**: Vibe CLI supports **local models**, enabling offline use.
|
||||
|
||||
### **📌 Migration Effort Summary**
|
||||
| **Approach** | **Complexity** | **Effort** | **Feature Parity** | **Recommended?** |
|
||||
|--------------|---------------|------------|--------------------|------------------|
|
||||
| **Vibe CLI (Direct Integration)** | Low-Medium | **1–2 weeks** | **~95%** | ✅ **Yes (Best Option)** |
|
||||
| **Vibe CLI + Wrapper** | Low | **1 week** | **100%** | ⚠️ **Only if protocol differences exist** |
|
||||
| **Mistral API** | High | **3–4 weeks** | **~70%** | ❌ **Not recommended** |
|
||||
|
||||
### **🚀 Recommended Path Forward**
|
||||
1. **Start with Direct Vibe CLI Integration**:
|
||||
- Replace `claude` with `vibe` in clide’s spawn logic.
|
||||
- Adapt `StreamJsonSession` and `TranscriptReader` for Vibe CLI’s output format.
|
||||
- Update `ClaudeConfig` to watch `.vibe/` instead of `.claude/`.
|
||||
- **Effort**: **1–2 weeks**.
|
||||
- **Outcome**: **Near-100% feature parity** with minimal changes.
|
||||
|
||||
2. **Test Thoroughly**:
|
||||
- Validate **all control requests** (permissions, prompts).
|
||||
- Test **session resumption** and **multi-agent teams** (via MCP).
|
||||
|
||||
3. **Fallback to Wrapper (If Needed)**:
|
||||
- If **protocol differences** are significant, use a **thin wrapper** to normalize Vibe CLI’s output.
|
||||
- **Effort**: **1 additional week**.
|
||||
|
||||
---
|
||||
---
|
||||
## **🔹 Conclusion: Vibe CLI Makes Migration Trivial**
|
||||
|
||||
**Vibe CLI changes the math dramatically**:
|
||||
- **Original (Mistral API)**: **3–4 weeks**, **major reimplementation**, **lost features**.
|
||||
- **With Vibe CLI**: **1–2 weeks**, **minimal changes**, **~95% feature parity**.
|
||||
|
||||
**Vibe CLI is the closest thing to a drop-in replacement for Claude Code CLI** in clide. The **stdio protocol, permission prompts, session persistence, and MCP support** make it **almost identical** to Claude from clide’s perspective. The migration would primarily involve:
|
||||
1. **Swapping `claude` for `vibe`** in spawn commands.
|
||||
2. **Adapting paths** (`.claude/` → `.vibe/`).
|
||||
3. **Minor adjustments** to event parsing and config probing.
|
||||
|
||||
**Final Answer**:
|
||||
**Yes, Vibe CLI changes the math entirely. With Vibe CLI, replacing Claude in clide is a low-effort, high-parity migration (1–2 weeks). Without Vibe CLI (raw Mistral API), it’s a high-effort, partial-parity migration (3–4 weeks). Vibe CLI is the clear winner.**
|
||||
@@ -144,6 +144,9 @@ You might also want, project-permitting:
|
||||
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
|
||||
- [D-101: ClideSettings — one live-preferences access facade](decisions/architecture.md#d-101-clidesettings--one-live-preferences-access-facade) — _architecture_
|
||||
- [D-102: i18n routing — ext-id namespaces, `core` catalog, ClideSettings.i18n facade, contribution keys](decisions/accessibility.md#d-102-i18n-routing--ext-id-namespaces-core-catalog-clidesettingsi18n-facade-contribution-keys) — _accessibility_
|
||||
- [D-103: Drawing-card primitive layer is SVG; the SVG renderer is the engine](decisions/architecture.md#d-103-drawing-card-primitive-layer-is-svg-the-svg-renderer-is-the-engine) — _architecture_
|
||||
- [D-104: Explicit supporter-binary path overrides in user-scope settings](decisions/tooling.md#d-104-explicit-supporter-binary-path-overrides-in-user-scope-settings) — _tooling_
|
||||
- [D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI](decisions/llm.md#d-105-support-vibe-cli-as-opt-in-alternative-to-claude-code-cli) — _llm_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -542,4 +542,13 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Cross-reference:** Fonts landed it: T-460 (Inter default + UI picker) and T-471 (mono picker) migrated ~93 sites onto `ClideSettings.fonts`. Consumer migration of theme + i18n onto the facade, and the context-less font stragglers (T-472), are staged follow-ups. Values live in the kernel `SettingsStore`.
|
||||
- **Raised by:** 2026-06-17 — user, during T-471 font-flow design: "I do see reason in nesting them all in one settings object that dynamically loads so we can extend it in the future… plumb once, use many."
|
||||
|
||||
### D-103: Drawing-card primitive layer is SVG; the SVG renderer is the engine
|
||||
- **Date:** 2026-06-28
|
||||
- **Decision:** The unified drawing card's primitive / scene-graph layer is **SVG**, not a bespoke `{type:"rect",…}` JSON vocabulary. The clide-owned `CustomPaint` SVG renderer (T-320) **is** the card's rendering engine — the foundation the rest builds on, not one template among many. The low-level escape hatch is "send SVG" (`template:"svg"` with inline `source`, or a `.svg` path); the high-level **templates** (image / icon / compare / d2) stay as JSON sugar but **lower to SVG** rather than to a custom primitive scene. A thin **Flutter overlay** renders the clide chrome that is *not* content — the per-object label/description caption widgets and the lightbox affordance — layered over the rendered SVG, anchored to elements via `data-label` / `data-description` / `data-lightbox` attributes. The **graph template is the exception:** it embeds the interactive native graph subsystem ([D-46], T-323) as a live widget rather than lowering to static SVG. Driven via `clide draw --file` (D-6); Flutter-free handler → `draw` MessageBus channel → Claude-extension injection.
|
||||
- **Rationale:** SVG already *is* a declarative, standard scene-graph with precisely the primitives [D-91] wanted (rect/line/text/image/path at coordinates, transforms). Since a clide-owned SVG renderer is being built for the `svg` template regardless, inventing a parallel primitive JSON + a second renderer duplicates the work for one job. Making SVG the substrate collapses two renderers and two schemas into one, makes external SVG / graphviz / mermaid / d2 output renderable for free, and keeps the `.canvas`-viewer reuse ([D-91]) on the same path. Owning the SVG painter (`CustomPaint`, no package) honors "own the rendering stack" — SVG here is a document *format*, not a third-party renderer, and explicitly **not** the imperative HTML Canvas 2D API ("HTML `<canvas>`" in D-91 was a mental model to reject Obsidian's `.canvas` schema, never an API to port).
|
||||
- **Cost:** The SVG-subset scope becomes the card's central design surface (the substrate, not one template) — but bounded, since the only SVG that must render is what clide's own templates + d2/graphviz emit; clide controls both ends. Re-sequences epic T-317: the SVG renderer (T-320) lands **before** the core envelope (T-318), inverting the prior `T-320 → T-318` dependency. The card is a hybrid (SVG content + Flutter overlay), so captions/interaction are not expressible in the document SVG itself.
|
||||
- **Amends [D-91]:** D-91's "raw primitives (rects/lines/text at coordinates)" are now SVG elements; "templates lower onto the same primitive scene" becomes "templates lower to SVG"; the renderer foundation is the SVG painter (T-320), not a separate primitive engine inside T-318 (which becomes the document envelope + template dispatch + the Flutter overlay).
|
||||
- **Cross-reference:** [D-91](#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer), [D-78], [D-46], T-317 (epic), T-318 (envelope/dispatch/overlay), T-320 (SVG engine), T-494 (d2→svg), T-313/T-316/T-319 (templates), T-321/T-323 (graph). Schema: `docs/design/drawing-card-schema.md`.
|
||||
- **Raised by:** 2026-06-28 — user, during the drawing-card schema draft: "if we are close to svg, are we not better off extending svg instead?" Confirmed the primitive layer should be SVG with templates lowering to it, a Flutter overlay for captions/interaction, and graph as the live-widget exception.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# LLM Integration Decisions
|
||||
|
||||
Decisions around Large Language Model integration, driver abstraction, and
|
||||
multi-LLM support in clide.
|
||||
|
||||
---
|
||||
|
||||
### D-105: Support Vibe CLI as opt-in alternative to Claude Code CLI
|
||||
|
||||
- **Date:** 2026-06-28
|
||||
- **Status:** confirmed
|
||||
- **Supersedes:** None
|
||||
- **See Also:** docs/spikes/vibe-cli-integration-analysis.md, D-6 (CLI/UI parity), D-56 (single process)
|
||||
- **Raised by:** Mistral Vibe evaluation
|
||||
|
||||
#### Decision
|
||||
|
||||
**We will support Vibe CLI as an opt-in alternative to Claude Code CLI in clide.**
|
||||
|
||||
Vibe CLI provides **stdio protocol, permission prompts, session persistence,
|
||||
and MCP support** — making it a **~95% feature-parity drop-in replacement** for
|
||||
Claude Code CLI. This enables users to select their preferred LLM driver on a
|
||||
per-repo basis while maintaining full backward compatibility with the
|
||||
existing Claude flow.
|
||||
|
||||
#### Context
|
||||
|
||||
Claude Code CLI is clide's current and primary LLM. User requested the
|
||||
ability to switch to Mistral's offerings. Two paths were considered:
|
||||
|
||||
1. **Raw Mistral API** — Requires reimplementing stdio protocol, permission
|
||||
prompts, session management. Effort: 3-4 weeks. Parity: ~70%.
|
||||
|
||||
2. **Vibe CLI** — Mistral's open-source CLI that **natively supports** stdio
|
||||
protocol, permission prompts (`can_use_tool`), session persistence
|
||||
(`--resume`), MCP servers, and JSONL transcripts. Effort: 1-2 weeks.
|
||||
Parity: ~95%.
|
||||
|
||||
The analysis in `docs/spikes/vibe-cli-integration-analysis.md` confirms Vibe CLI is the
|
||||
**production-ready path**.
|
||||
|
||||
#### Architectural Choice
|
||||
|
||||
**Direct Vibe CLI Integration (Option 1 from analysis):**
|
||||
- Replace `claude` with `vibe` in clide's spawn logic
|
||||
- Adapt `StreamJsonProcess` and `TranscriptReader` for minor JSONL differences
|
||||
- Update `ClaudeConfig` to watch `.vibe/` instead of `.claude/`
|
||||
- Reuse clide's existing MCP broker for team orchestration
|
||||
|
||||
**Fallback (Option 2):** Thin wrapper to normalize Vibe CLI output to match
|
||||
Claude's stream-json exactly. Only if protocol differences prove significant
|
||||
during testing.
|
||||
|
||||
#### Implementation Constraints
|
||||
|
||||
- **Claude remains primary** — Vibe CLI is opt-in; default driver stays `claude`
|
||||
- **Per-repo setting** — `llm.driver` in `.clide/config.yaml`, similar to multi-
|
||||
Claude-account mechanism
|
||||
- **Zero regression** — All existing Claude functionality preserved
|
||||
- **Minimal abstraction** — Leverage Vibe CLI's native parity; avoid thick
|
||||
conversion layers
|
||||
|
||||
#### Success Criteria
|
||||
|
||||
- User can select `llm.driver: claude | mistral` in per-repo config
|
||||
- Vibe CLI sessions work end-to-end: streaming, permissions, tools, MCP, session resume
|
||||
- `make test` passes with both drivers
|
||||
- No performance regression in Claude flow
|
||||
- Migration effort: 1-2 weeks
|
||||
|
||||
#### Consequences
|
||||
|
||||
**Positive:**
|
||||
- Users can choose their preferred LLM
|
||||
- Local inference support (offline/private use cases)
|
||||
- Minimal code changes (~1-2 weeks)
|
||||
- Full feature parity maintained
|
||||
|
||||
**Negative:**
|
||||
- Additional binary dependency (Vibe CLI)
|
||||
- Maintenance burden for two drivers (mitigated by abstraction)
|
||||
- Testing matrix doubles (mitigated by CI feature flags)
|
||||
|
||||
---
|
||||
@@ -48,7 +48,7 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
|
||||
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
|
||||
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
|
||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates (tracked in T-88). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates — `make dugite-check` compares the Makefile pin against the latest upstream release and flags CVE mentions; run quarterly or on a git CVE (the calendar is T-88; the bump machine is D-63 / T-25). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||
- **Raised by:** 2026-04-25 macOS sandbox investigation.
|
||||
|
||||
@@ -110,3 +110,11 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
|
||||
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
|
||||
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
|
||||
|
||||
### D-104: Explicit supporter-binary path overrides in user-scope settings
|
||||
- **Date:** 2026-06-28
|
||||
- **Decision:** clide resolves each **external** supporter binary (`claude`, `d2`, and future supporter tools — **not** bundled `pql`/`git`, which keep [D-58]/[D-59]) through an **explicit `tools:` map in user-scope settings**: tool-name → absolute path. The map holds concrete, user-visible, user-editable paths and is the **first** step in resolution — if an entry is set, clide uses that exact path (honest error if it is missing / not executable, falling **down the chain with a warning** rather than hard-failing). On **first run**, clide auto-detects each tool **once** — probing the login-shell PATH (T-439) plus well-known dirs (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew`, Homebrew-on-Linux `/home/linuxbrew/.linuxbrew/bin`) — and **writes the discovered absolute paths into the map**, so detection is a one-time *populate*, not a per-launch heuristic; thereafter the pinned explicit value wins. A **re-detect** action re-runs the probe (e.g. after installing a tool). Unset/undetected tools fall back to the existing chain (bundled/pinned per [D-58]/[D-59], then login-shell PATH). **User-scope only, keyed by machine ([D-93]) — never committed** (absolute paths are machine-specific). Generalizes [D-58]'s `CLIDE_PQL_BIN` override to every supporter tool; surfaced in the Config tab and the Problems panel for unresolved tools.
|
||||
- **Rationale:** PATH-probing (T-439) is adaptive but brittle on non-standard installs — a login-shell probe misses Homebrew when `brew shellenv` lives only in `~/.bashrc` (login shells source `.bash_profile`/`.profile`, not `.bashrc`). Materializing resolution into explicit, pinned paths makes it deterministic and debuggable: "tool not found" becomes a one-line settings fix, and the value is *visible* rather than recomputed by heuristic each launch. First-run auto-detect keeps it zero-config for standard installs; **pinning the result instead of re-probing is what makes it explicit** — escaping the heuristic fragility while keeping the convenience.
|
||||
- **Cost:** A first-run detection pass plus a small settings surface. Stale pins (a tool moved on a brew upgrade) must fall back + warn, not hard-fail. The map is per-machine, so it does not travel with the repo — each machine detects once.
|
||||
- **Cross-reference:** [D-58](#d-58-ship-pql-bundled-with-clide) (pql resolution + `CLIDE_PQL_BIN` override), [D-59](#d-59-bundled-git-via-dugite-native), [D-93](architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) (user-scope state), T-439 (login-shell-derived PATH), T-494 (the d2 template — first consumer).
|
||||
- **Raised by:** 2026-06-28 — user, during the drawing-card SVG/d2 work after the login-shell PATH probe was seen to miss linuxbrew: "should we just add the hard paths to the supporting binaries to the clide settings file?" + "auto detect on first run sounds solid" — explicit pinned paths, populated by a one-time first-run probe.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/// Modal that hosts `CLAUDE_CONFIG_DIR=<dir> claude login` in a terminal pane
|
||||
/// (T-485, epic T-476). clide writes no auth code: the Claude CLI owns the OAuth
|
||||
/// browser flow, and clide just provides the TTY + the per-account config dir,
|
||||
/// so the resulting credentials land in `<dir>` rather than the global
|
||||
/// `~/.claude` (D-64 — one CLI-initiated browser flow, on explicit action,
|
||||
/// nothing in the background). No-Material (D-7); shown via the DialogRouter.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/terminal/src/terminal_pane.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeLoginDialog extends StatelessWidget {
|
||||
const ClaudeLoginDialog({super.key, required this.name, required this.dir, required this.onClose, this.cwd});
|
||||
|
||||
/// Account display name (for the title).
|
||||
final String name;
|
||||
|
||||
/// The account's `CLAUDE_CONFIG_DIR` — where `claude login` writes credentials.
|
||||
final String dir;
|
||||
|
||||
/// Working directory for the spawned `claude login` (defaults to the
|
||||
/// workspace); irrelevant to auth, but keeps the pane oriented.
|
||||
final String? cwd;
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 760,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.panelBackground,
|
||||
border: Border.all(color: theme.globalBorder),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Sign in: $name', fontSize: clideFontBody, color: theme.globalForeground),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Close',
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
key: const Key('account-login-close'),
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onClose,
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.byName('x'), size: 14, color: hovered ? theme.globalForeground : theme.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: ClideText('Running `claude login` against $dir — finish the browser sign-in, then close.', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
// Fixed height — TerminalPane needs a bounded box; the dialog itself
|
||||
// sizes to its content (mainAxisSize.min).
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: SizedBox(
|
||||
height: 380,
|
||||
child: TerminalPane(argv: const ['claude', 'login'], env: {'CLAUDE_CONFIG_DIR': dir}, cwdOverride: cwd),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/// Per-repo Claude account registry (T-483, epic T-476) — the user-scope
|
||||
/// persistence + typed reader/writer every other child of the epic consumes.
|
||||
///
|
||||
/// Two durable concerns, both in the app (user) settings layer so they are
|
||||
/// PER-USER, never committed to a repo:
|
||||
///
|
||||
/// - **Account registry** `app.claude.accounts` — a list of `{name, dir}`
|
||||
/// pairs: the Claude accounts this user set up. `dir` is the
|
||||
/// `CLAUDE_CONFIG_DIR` Claude Code reads from for that account.
|
||||
/// - **Workspace binding** `app.claude.account.<workspace-hash>` → account
|
||||
/// name. The hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket
|
||||
/// path ([fnv1a64Hex]/[canonicalWorkspaceKey]), so a workspace's account and
|
||||
/// its socket agree. Unset = use Claude's default (no injection).
|
||||
///
|
||||
/// Flutter-free (foundation only, via SettingsStore); no process spawning, no
|
||||
/// UI, no CLI — those are downstream tickets (T-484/T-480/T-481/T-482).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
|
||||
|
||||
/// One configured Claude account: a user-chosen [name] (`personal`, `work`,
|
||||
/// `client-acme`) and the [dir] Claude Code reads as `CLAUDE_CONFIG_DIR`.
|
||||
class Account {
|
||||
const Account({required this.name, required this.dir});
|
||||
|
||||
final String name;
|
||||
final String dir;
|
||||
|
||||
Map<String, String> toJson() => {'name': name, 'dir': dir};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Account && other.name == name && other.dir == dir;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, dir);
|
||||
|
||||
@override
|
||||
String toString() => 'Account($name → $dir)';
|
||||
}
|
||||
|
||||
/// A `~/.claude-*` directory the bootstrap probe found that looks like a Claude
|
||||
/// config dir — an adoption candidate. [name] is the suggested account name
|
||||
/// (the suffix after `.claude-`); registering it is welcome-view UX (T-481).
|
||||
class DetectedAccount {
|
||||
const DetectedAccount({required this.name, required this.dir});
|
||||
|
||||
final String name;
|
||||
final String dir;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is DetectedAccount && other.name == name && other.dir == dir;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, dir);
|
||||
}
|
||||
|
||||
class AccountRegistry {
|
||||
AccountRegistry(this._store);
|
||||
|
||||
final SettingsStore _store;
|
||||
|
||||
/// Key holding the `{name, dir}` account list (app/user scope).
|
||||
static const accountsKey = 'app.claude.accounts';
|
||||
|
||||
/// Per-workspace binding key: account name keyed by workspace hash.
|
||||
static String bindingKey(String cwd) => 'app.claude.account.${workspaceHash(cwd)}';
|
||||
|
||||
/// FNV-1a 64-bit hex of the canonicalised workspace root — the SAME hash D-70
|
||||
/// derives for the socket path, so a workspace's binding and its socket
|
||||
/// agree. Trailing separators are stripped so `/repo` and `/repo/` map alike.
|
||||
static String workspaceHash(String cwd) => fnv1a64Hex(canonicalWorkspaceKey(_stripTrailingSep(cwd)));
|
||||
|
||||
static String _stripTrailingSep(String p) {
|
||||
var s = p;
|
||||
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/// The configured accounts, in stored order. Tolerant of a malformed or
|
||||
/// partially-written entry (skips anything missing a string name + dir).
|
||||
List<Account> get accounts {
|
||||
final raw = _store.get<List>(accountsKey);
|
||||
if (raw == null) return const [];
|
||||
final out = <Account>[];
|
||||
for (final e in raw) {
|
||||
if (e is Map && e['name'] is String && e['dir'] is String) {
|
||||
out.add(Account(name: e['name'] as String, dir: e['dir'] as String));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Account? accountByName(String name) {
|
||||
for (final a in accounts) {
|
||||
if (a.name == name) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The account bound to [cwd], or null when unbound (or bound to a name that
|
||||
/// no longer exists — treated as unbound, so a removed account degrades to
|
||||
/// Claude's default rather than erroring).
|
||||
Account? accountForWorkspace(String cwd) {
|
||||
final name = _store.get<String>(bindingKey(cwd));
|
||||
return name == null ? null : accountByName(name);
|
||||
}
|
||||
|
||||
/// The raw account NAME bound to [cwd] — independent of whether that account
|
||||
/// still exists in the registry — or null when unbound. (`accountForWorkspace`
|
||||
/// resolves to the Account and is null for a dangling binding; this is the
|
||||
/// stored name, for list/unset reporting.)
|
||||
String? boundName(String cwd) => _store.get<String>(bindingKey(cwd));
|
||||
|
||||
/// Every account name some workspace is bound to — for "is this account in
|
||||
/// use" checks before removal (T-480). Scans the `app.claude.account.<hash>`
|
||||
/// binding keys (NOT the `app.claude.accounts` list, a different key).
|
||||
Set<String> boundAccountNames() {
|
||||
const prefix = 'app.claude.account.';
|
||||
final out = <String>{};
|
||||
for (final key in _store.keysAt(SettingsScope.app)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
final v = _store.get<String>(key);
|
||||
if (v != null) out.add(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Add (or replace, by name) an account. New default dir is the caller's
|
||||
/// concern (T-480); the registry stores whatever [dir] it's given.
|
||||
Future<void> registerAccount(String name, String dir) async {
|
||||
await _writeAccounts([...accounts.where((a) => a.name != name), Account(name: name, dir: dir)]);
|
||||
}
|
||||
|
||||
Future<void> removeAccount(String name) async {
|
||||
await _writeAccounts(accounts.where((a) => a.name != name).toList());
|
||||
}
|
||||
|
||||
Future<void> bindWorkspace(String cwd, String name) async {
|
||||
await _store.setAt(SettingsScope.app, bindingKey(cwd), name);
|
||||
}
|
||||
|
||||
Future<void> unbindWorkspace(String cwd) async {
|
||||
await _store.removeAt(SettingsScope.app, bindingKey(cwd));
|
||||
}
|
||||
|
||||
Future<void> _writeAccounts(List<Account> list) async {
|
||||
await _store.setAt(SettingsScope.app, accountsKey, [for (final a in list) a.toJson()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort, read-only check of whether an account config dir holds live
|
||||
/// credentials (T-482) — for the "signed in / not signed in" indicator. True
|
||||
/// when the dir has a `.credentials.json` (Linux/Windows) or its `.claude.json`
|
||||
/// carries an `oauthAccount` marker. Never mutates; under-reports on macOS,
|
||||
/// where Claude Code keeps credentials in the system keychain rather than a file.
|
||||
bool accountIsSignedIn(String dir) {
|
||||
if (File('$dir/.credentials.json').existsSync()) return true;
|
||||
final cfg = File('$dir/.claude.json');
|
||||
if (!cfg.existsSync()) return false;
|
||||
try {
|
||||
return cfg.readAsStringSync().contains('"oauthAccount"');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [dir] is safe to `rm -rf` as a purged account config dir
|
||||
/// (`remove --purge`, T-480): it must be a `~/.claude-*` directory that is a
|
||||
/// DIRECT child of [home]. Anything else — an absolute path elsewhere, a nested
|
||||
/// path, the real `~/.claude` — is rejected even though the path came from our
|
||||
/// own registry. A wrong recursive delete is unrecoverable, so the predicate
|
||||
/// is deliberately strict.
|
||||
bool isPurgeableAccountDir(String dir, String home) {
|
||||
if (home.isEmpty) return false;
|
||||
final base = dir.split('/').last;
|
||||
return dir == '$home/$base' && base.startsWith('.claude-');
|
||||
}
|
||||
|
||||
/// Bootstrap probe (T-483): existing `~/.claude-*` directories that look like a
|
||||
/// Claude config dir (have a `.claude.json` file or a `sessions/` dir), as
|
||||
/// adoption candidates. Pure read — mutates nothing; the welcome view (T-481)
|
||||
/// decides whether to register them. Sorted by suggested name.
|
||||
List<DetectedAccount> probeExistingAccountDirs(String home) {
|
||||
final out = <DetectedAccount>[];
|
||||
final dir = Directory(home);
|
||||
if (!dir.existsSync()) return out;
|
||||
for (final entry in dir.listSync(followLinks: false)) {
|
||||
if (entry is! Directory) continue;
|
||||
final base = entry.path.split(Platform.pathSeparator).last;
|
||||
if (!base.startsWith('.claude-')) continue;
|
||||
final looksLikeConfig = File('${entry.path}/.claude.json').existsSync() || Directory('${entry.path}/sessions').existsSync();
|
||||
if (!looksLikeConfig) continue;
|
||||
out.add(DetectedAccount(name: base.substring('.claude-'.length), dir: entry.path));
|
||||
}
|
||||
out.sort((a, b) => a.name.compareTo(b.name));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// The per-repo account roadblock shown right after a new project is created
|
||||
/// (T-488, story T-486). Only a freshly-created project reaches here (the
|
||||
/// welcome dialog announces it on projectCreatedChannel) — existing opens never
|
||||
/// prompt. The new project is already the open workspace, so the embedded
|
||||
/// per-workspace picker binds it directly; the accounts list lets a first-run
|
||||
/// user add + sign in to an account before picking. No-Material (D-7).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeAccountRoadblockDialog extends StatelessWidget {
|
||||
const ClaudeAccountRoadblockDialog({super.key, required this.projectName, required this.onClose});
|
||||
|
||||
final String projectName;
|
||||
final VoidCallback onClose;
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 560,
|
||||
constraints: const BoxConstraints(maxHeight: 540),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.modalSurfaceBackground,
|
||||
border: Border.all(color: theme.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideText('Claude account for $projectName', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600, color: theme.globalForeground),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(
|
||||
'Pick which Claude account this new project runs under, or keep the default system login. You can change it later in Settings or the pane badge.',
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
ClideText('Account for this project', fontSize: clideFontMeta, muted: true),
|
||||
const SizedBox(height: 6),
|
||||
const Align(alignment: Alignment.centerLeft, child: ClaudeWorkspaceAccountControl()),
|
||||
const SizedBox(height: 18),
|
||||
ClideText('Accounts', fontSize: clideFontMeta, muted: true),
|
||||
const SizedBox(height: 6),
|
||||
const ClaudeAccountsListControl(),
|
||||
const SizedBox(height: 18),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ClideButton(label: 'Continue', onPressed: onClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Bind (or, with [name] null, unbind) [cwd] to a Claude account and publish on
|
||||
/// [accountActionChannel] so the session respawns (T-480) and the IDE lock
|
||||
/// re-syncs (T-479). The registry write sets the in-memory binding
|
||||
/// synchronously then flushes; publishing before the flush keeps the bus
|
||||
/// consumers in step. Shared by the settings picker and the pane badge (T-481).
|
||||
Future<void> bindWorkspaceAccount(KernelServices services, String cwd, String? name) async {
|
||||
final reg = AccountRegistry(services.settings);
|
||||
if (name == null) {
|
||||
final previous = reg.boundName(cwd);
|
||||
final write = reg.unbindWorkspace(cwd);
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': previous});
|
||||
await write;
|
||||
} else {
|
||||
final write = reg.bindWorkspace(cwd, name);
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
|
||||
await write;
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable, token-derived accent for an account [name] so each window's badge
|
||||
/// reads at a glance (T-481). Hash-indexed into a fixed set of theme tokens —
|
||||
/// never an arbitrary colour (the palette stays theme-owned). Null name (the
|
||||
/// default account) returns the muted token.
|
||||
Color accountAccent(String? name, SurfaceTokens tokens) {
|
||||
if (name == null || name.isEmpty) return tokens.globalTextMuted;
|
||||
final accents = [tokens.globalFocus, tokens.statusSuccess, tokens.statusWarning, tokens.statusError, tokens.buttonBackground];
|
||||
var h = 0;
|
||||
for (final unit in name.codeUnits) {
|
||||
h = (h * 31 + unit) & 0x7fffffff;
|
||||
}
|
||||
return accents[h % accents.length];
|
||||
}
|
||||
|
||||
/// Settings control for "Account for this workspace" (T-482, epic T-476). A
|
||||
/// dropdown of the registered Claude accounts plus a Default option; picking one
|
||||
/// binds (or unbinds) the current workspace and publishes on
|
||||
/// [accountActionChannel] — the same channel the CLI `set`/`unset` verbs use, so
|
||||
/// the session respawns onto the account (T-480) and the IDE lock re-syncs
|
||||
/// (T-479). Registry writes flow through the shared [SettingsStore], whose
|
||||
/// notifier this control listens to, so it stays live for both UI and CLI edits.
|
||||
class ClaudeWorkspaceAccountControl extends StatefulWidget {
|
||||
const ClaudeWorkspaceAccountControl({super.key});
|
||||
|
||||
@override
|
||||
State<ClaudeWorkspaceAccountControl> createState() => _ClaudeWorkspaceAccountControlState();
|
||||
}
|
||||
|
||||
class _ClaudeWorkspaceAccountControlState extends State<ClaudeWorkspaceAccountControl> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
SettingsStore? _settings;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final settings = ClideKernel.maybeOf(context)?.settings;
|
||||
if (identical(settings, _settings)) return;
|
||||
_settings?.removeListener(_onChange);
|
||||
_settings = settings;
|
||||
_settings?.addListener(_onChange);
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settings?.removeListener(_onChange);
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const _defaultLabel = 'Default';
|
||||
|
||||
Future<void> _bind(KernelServices services, String cwd, String? name) async {
|
||||
_overlay.close();
|
||||
await bindWorkspaceAccount(services, cwd, name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final services = ClideKernel.maybeOf(context);
|
||||
final cwd = services?.settings.projectDir?.path;
|
||||
if (services == null || cwd == null) {
|
||||
return ClideText('Open a workspace to bind a Claude account.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
|
||||
}
|
||||
|
||||
final reg = AccountRegistry(services.settings);
|
||||
final accounts = reg.accounts;
|
||||
if (accounts.isEmpty) {
|
||||
return ClideText('No accounts yet — add one with `clide claude account add <name>`.', fontSize: clideFontCaption, color: tokens.globalTextMuted);
|
||||
}
|
||||
|
||||
final boundName = reg.boundName(cwd);
|
||||
final selectedLabel = boundName ?? _defaultLabel;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.start,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
ClideMenuItem(
|
||||
label: _defaultLabel,
|
||||
active: boundName == null,
|
||||
semanticLabel: 'Account for this workspace: $_defaultLabel',
|
||||
onSelect: () => _bind(services, cwd, null),
|
||||
),
|
||||
const ClideMenuSeparator(),
|
||||
for (final a in accounts)
|
||||
ClideMenuItem(
|
||||
label: a.name,
|
||||
active: a.name == boundName,
|
||||
semanticLabel: 'Account for this workspace: ${a.name}',
|
||||
onSelect: () => _bind(services, cwd, a.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: 'Account for this workspace: $selectedLabel. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(selectedLabel, color: tokens.globalForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings control for the global Claude accounts registry (T-482, epic
|
||||
/// T-476). Lists each registered account — sign-in status dot, name, config
|
||||
/// dir — with re-login and remove affordances, plus an inline "add account"
|
||||
/// field. Management flows through the AccountRegistry + accountActionChannel
|
||||
/// (the CLI verbs' path); removal is refused while a workspace is bound, to
|
||||
/// match `clide claude account remove`.
|
||||
class ClaudeAccountsListControl extends StatefulWidget {
|
||||
const ClaudeAccountsListControl({super.key});
|
||||
|
||||
@override
|
||||
State<ClaudeAccountsListControl> createState() => _ClaudeAccountsListControlState();
|
||||
}
|
||||
|
||||
class _ClaudeAccountsListControlState extends State<ClaudeAccountsListControl> {
|
||||
final TextEditingController _name = TextEditingController();
|
||||
final FocusNode _focus = FocusNode(debugLabel: 'add-account');
|
||||
SettingsStore? _settings;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final settings = ClideKernel.maybeOf(context)?.settings;
|
||||
if (identical(settings, _settings)) return;
|
||||
_settings?.removeListener(_onChange);
|
||||
_settings = settings;
|
||||
_settings?.addListener(_onChange);
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settings?.removeListener(_onChange);
|
||||
_name.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _add(KernelServices services) async {
|
||||
final name = _name.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
final reg = AccountRegistry(services.settings);
|
||||
if (reg.accountByName(name) != null) {
|
||||
_name.clear();
|
||||
return; // idempotent — already registered
|
||||
}
|
||||
final dir = '${Platform.environment['HOME'] ?? ''}/.claude-$name';
|
||||
_name.clear();
|
||||
final write = reg.registerAccount(name, dir);
|
||||
// Kick off the login flow for the new account (T-485 consumer opens it).
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': name, 'dir': dir});
|
||||
await write;
|
||||
}
|
||||
|
||||
void _relogin(KernelServices services, Account a) {
|
||||
services.messages.publish('ui', accountActionChannel, {'action': 'login', 'name': a.name, 'dir': a.dir});
|
||||
}
|
||||
|
||||
Future<void> _remove(KernelServices services, String name) => AccountRegistry(services.settings).removeAccount(name);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final services = ClideKernel.maybeOf(context);
|
||||
if (services == null) return const SizedBox.shrink();
|
||||
final reg = AccountRegistry(services.settings);
|
||||
final accounts = reg.accounts;
|
||||
final bound = reg.boundAccountNames();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (accounts.isEmpty)
|
||||
ClideText('No accounts registered yet.', fontSize: clideFontCaption, color: tokens.globalTextMuted)
|
||||
else
|
||||
for (final a in accounts) _row(context, services, tokens, a, bound.contains(a.name)),
|
||||
const SizedBox(height: 10),
|
||||
_addRow(context, services, tokens),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(BuildContext context, KernelServices services, SurfaceTokens tokens, Account a, bool isBound) {
|
||||
final signedIn = accountIsSignedIn(a.dir);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: signedIn ? tokens.statusSuccess : tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(a.name, color: tokens.globalForeground),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ClideText(a.dir, fontSize: clideFontCaption, muted: true, fontFamily: ClideSettings.fonts.monoOf(context), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_iconButton(context, 'sign-in', signedIn ? 'Re-sign in to ${a.name}' : 'Sign in to ${a.name}', () => _relogin(services, a)),
|
||||
const SizedBox(width: 6),
|
||||
_iconButton(
|
||||
context,
|
||||
'trash',
|
||||
isBound ? '${a.name} is bound to a workspace — unset it first' : 'Remove ${a.name}',
|
||||
isBound ? null : () => _remove(services, a.name),
|
||||
color: isBound ? tokens.globalTextMuted : tokens.statusError,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconButton(BuildContext context, String icon, String semantic, VoidCallback? onTap, {Color? color}) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: onTap != null,
|
||||
label: semantic,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.byName(icon), size: 14, color: color ?? (hovered ? tokens.globalForeground : tokens.globalTextMuted)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _addRow(BuildContext context, KernelServices services, SurfaceTokens tokens) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 26,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _name,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) => _add(services),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Add account',
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => _add(services),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
|
||||
child: ClideText('Add account', color: tokens.buttonForeground, fontSize: clideFontCaption),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact account badge for the Claude pane chrome (T-481, epic T-476). Shows
|
||||
/// the workspace's bound account (or "default"), tinted by [accountAccent] so
|
||||
/// each window is distinguishable at a glance. Tapping opens a picker of the
|
||||
/// registered accounts + Default. Hidden when no accounts are registered (the
|
||||
/// feature is unused). Live via the settings notifier.
|
||||
class ClaudeAccountBadge extends StatefulWidget {
|
||||
const ClaudeAccountBadge({super.key, required this.workspaceRoot});
|
||||
|
||||
final String? workspaceRoot;
|
||||
|
||||
@override
|
||||
State<ClaudeAccountBadge> createState() => _ClaudeAccountBadgeState();
|
||||
}
|
||||
|
||||
class _ClaudeAccountBadgeState extends State<ClaudeAccountBadge> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
SettingsStore? _settings;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final settings = ClideKernel.maybeOf(context)?.settings;
|
||||
if (identical(settings, _settings)) return;
|
||||
_settings?.removeListener(_onChange);
|
||||
_settings = settings;
|
||||
_settings?.addListener(_onChange);
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settings?.removeListener(_onChange);
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pick(KernelServices services, String? name) async {
|
||||
_overlay.close();
|
||||
final cwd = widget.workspaceRoot;
|
||||
if (cwd != null) await bindWorkspaceAccount(services, cwd, name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final services = ClideKernel.maybeOf(context);
|
||||
final cwd = widget.workspaceRoot;
|
||||
if (services == null || cwd == null) return const SizedBox.shrink();
|
||||
final reg = AccountRegistry(services.settings);
|
||||
final accounts = reg.accounts;
|
||||
if (accounts.isEmpty) return const SizedBox.shrink(); // feature unused — no chrome noise
|
||||
final boundName = reg.boundName(cwd);
|
||||
final label = boundName ?? 'default';
|
||||
final accent = accountAccent(boundName, tokens);
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
ClideMenuItem(label: 'default', active: boundName == null, semanticLabel: 'Account: default', onSelect: () => _pick(services, null)),
|
||||
const ClideMenuSeparator(),
|
||||
for (final a in accounts)
|
||||
ClideMenuItem(label: a.name, active: a.name == boundName, semanticLabel: 'Account: ${a.name}', onSelect: () => _pick(services, a.name)),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: 'Claude account: $label. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
border: Border.all(color: accent),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: accent),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
ClideText(label, fontSize: clideFontCaption, color: boundName == null ? tokens.globalTextMuted : tokens.globalForeground),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,8 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
case UserMessage():
|
||||
case AssistantTextMessage():
|
||||
case ImageMessage():
|
||||
case DrawingMessage():
|
||||
case IconMessage():
|
||||
return false;
|
||||
// Thinking folds at L2+, first-class at L1.
|
||||
case AssistantThinkingMessage():
|
||||
|
||||
@@ -58,6 +58,16 @@ String clideContextNote(String workspaceRoot) =>
|
||||
'is how you observe and drive the same workspace the user sees — prefer it for IDE actions so '
|
||||
'your work and the user\'s stay in one shared workspace.';
|
||||
|
||||
/// Nudge a FRESH session to reach for the bundled skills from its first turn
|
||||
/// rather than rediscovering the workflows (T-490). Layered on top of
|
||||
/// [clideContextNote]; only injected for new sessions (not --resume / forks),
|
||||
/// so a session that already carries context is never re-nagged.
|
||||
String clideSkillsNote() =>
|
||||
'Two skills are available in this workspace — load and use them from your first turn instead '
|
||||
'of rediscovering their workflows: `pql` (planning, decisions, tickets, and vault queries) and '
|
||||
'`clide` (driving this IDE). Reach for the matching skill whenever a task touches planning or '
|
||||
'tickets, or the clide surface.';
|
||||
|
||||
/// Build the environment DELTA to overlay on a hosted session's inherited
|
||||
/// environment (T-215). `Process.start` keeps the parent environment by
|
||||
/// default, so this returns only the keys to add/override:
|
||||
@@ -74,6 +84,21 @@ Map<String, String> agentEnvDelta({required String workspaceRoot, required Strin
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// Resolve the `CLAUDE_CONFIG_DIR` a session in [cwd] should run under (T-484,
|
||||
/// epic T-476): the bound account's dir when the workspace is bound, else the
|
||||
/// parent's `CLAUDE_CONFIG_DIR` when the launcher already set one, else null
|
||||
/// (Claude defaults to `~/.claude`).
|
||||
///
|
||||
/// Pure: the AccountRegistry is injected as a plain [boundConfigDir] lookup
|
||||
/// (workspace → bound config dir, or null) so this stays Flutter-free — the
|
||||
/// registry itself lives behind a ChangeNotifier the orchestrator owns.
|
||||
String? claudeConfigDirForWorkspace({required String cwd, required String? Function(String cwd) boundConfigDir, required Map<String, String> env}) {
|
||||
final bound = boundConfigDir(cwd);
|
||||
if (bound != null && bound.isNotEmpty) return bound;
|
||||
final inherited = env['CLAUDE_CONFIG_DIR'];
|
||||
return (inherited != null && inherited.isNotEmpty) ? inherited : null;
|
||||
}
|
||||
|
||||
/// Locate the directory to prepend to a hosted agent's PATH so `clide`
|
||||
/// resolves (T-215). Returns null when `clide` is ALREADY on [currentPath]
|
||||
/// (the installed case — T-211 drops it in `~/.local/bin`, normally already
|
||||
@@ -107,7 +132,7 @@ class AgentBootstrap {
|
||||
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
|
||||
/// carries the context note; team callers append their own preamble and the
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
|
||||
// shells out to — find user-installed components on a desktop launch, not just
|
||||
@@ -120,7 +145,12 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
];
|
||||
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]);
|
||||
// Per-repo Claude account (T-484): a bound workspace runs claude under that
|
||||
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
|
||||
// SpawnSpec.env override still wins (precedence: override > binding > parent
|
||||
// env > unset); omitted entirely when there's nothing to set.
|
||||
final configDir = claudeConfigDirForWorkspace(cwd: workspaceRoot, boundConfigDir: boundConfigDir ?? (_) => null, env: Platform.environment);
|
||||
return AgentBootstrap(envDelta: {'CLAUDE_CONFIG_DIR': ?configDir, ...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
}
|
||||
|
||||
bool _isExecutableFile(String path) {
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
/// `meta_sidebar/` (T-395 split). Activity and Config render on the same
|
||||
/// table geometry (`buildMetaTable`) so switching tabs doesn't visually jump.
|
||||
///
|
||||
/// The account/team token budget is intentionally absent: it isn't
|
||||
/// programmatically exposed under subscription auth (see project memory /
|
||||
/// GitHub anthropics/claude-code#44328).
|
||||
/// The account budget surfaces from a forwarded `/usage` (T-415): the Activity
|
||||
/// tab renders it next to its refresh control. It is NOT duplicated on the Team
|
||||
/// tab — usage is per-account (one `~/.claude` login), so it can't be split per
|
||||
/// member; one place to see it is enough (T-158).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'account_settings_control.dart';
|
||||
import 'claude_banner.dart';
|
||||
import 'claude_composer.dart';
|
||||
import 'claude_config.dart';
|
||||
@@ -728,7 +729,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final title = widget.isPrimary
|
||||
? ClideSettings.i18n.string(context, 'pane.title.primary', namespace: 'builtin.claude', placeholder: 'claude — primary')
|
||||
: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'pane.title.secondary',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'claude — secondary ${widget.secondaryIndex}',
|
||||
replacers: [I18nReplacer(from: '{index}', replace: '${widget.secondaryIndex}')],
|
||||
);
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
final Widget body;
|
||||
@@ -761,7 +770,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
role: widget.isPrimary
|
||||
? ClideSettings.i18n.string(context, 'banner.role.primary', namespace: 'builtin.claude', placeholder: 'primary')
|
||||
: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'banner.role.secondary',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: 'session ${widget.secondaryIndex}',
|
||||
replacers: [I18nReplacer(from: '{index}', replace: '${widget.secondaryIndex}')],
|
||||
),
|
||||
workspace: _repoRoot,
|
||||
statusLine: _statusLine,
|
||||
),
|
||||
@@ -838,7 +855,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
);
|
||||
}
|
||||
|
||||
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
|
||||
final content = widget.showChrome
|
||||
? ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
// Per-repo Claude account badge (T-481): shows + switches the
|
||||
// account this workspace is bound to; hidden when none registered.
|
||||
trailing: [ClaudeAccountBadge(workspaceRoot: _repoRoot)],
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
|
||||
// Surface this pane's status to the bottom status-bar slot while it's
|
||||
// the focused pane (T-150).
|
||||
|
||||
@@ -12,6 +12,8 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data' show Uint8List;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
@@ -19,9 +21,14 @@ import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabe
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:clide/builtin/claude/src/icon_card.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/src/svg/svg_document.dart' show buildSvgDocument;
|
||||
import 'package:clide/src/svg/svg_node.dart' show SvgDocument;
|
||||
import 'package:clide/widgets/src/draw/drawing_card.dart';
|
||||
import 'package:clide/widgets/src/svg/svg_painter.dart' show SvgView;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
@@ -701,9 +708,41 @@ class _ConversationTurn extends StatelessWidget {
|
||||
AssistantToolUse() => collapseTools ? _toolUseCollapser(context, i) : _toolContentCard(context, i),
|
||||
ToolResultMessage() => _toolResult(context, i),
|
||||
ImageMessage() => _image(context, i),
|
||||
DrawingMessage() => _drawing(context, i),
|
||||
IconMessage() => _icon(context, i),
|
||||
};
|
||||
}
|
||||
|
||||
/// A driven-in drawing card (T-318): the SVG rendered inline by clide's own
|
||||
/// CustomPaint engine (D-103), display-only per D-78, with an optional
|
||||
/// label/description caption.
|
||||
Widget _drawing(BuildContext context, DrawingMessage m) {
|
||||
return ConversationCard(
|
||||
accent: tokens.globalTextMuted,
|
||||
label: ClideSettings.i18n.string(context, 'conversation.label.drawing', namespace: 'builtin.claude', placeholder: 'drawing'),
|
||||
body: _DrawingWithImages(
|
||||
doc: buildSvgDocument(m.svg),
|
||||
label: m.label,
|
||||
description: m.description,
|
||||
source: m.source,
|
||||
sourceLabel: m.source == null
|
||||
? null
|
||||
: ClideSettings.i18n.string(context, 'conversation.draw.viewSource', namespace: 'builtin.claude', placeholder: 'view d2 source'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A driven-in Phosphor glyph card (T-313): each glyph at a hero size plus a
|
||||
/// real-UI-size strip, with optional label/description/color. Display-only
|
||||
/// per D-78 — selection happens in the interaction zone, not on the card.
|
||||
Widget _icon(BuildContext context, IconMessage m) {
|
||||
return ConversationCard(
|
||||
accent: tokens.globalTextMuted,
|
||||
label: ClideSettings.i18n.string(context, 'conversation.label.icon', namespace: 'builtin.claude', placeholder: 'icons'),
|
||||
body: IconGlyphCard(entries: m.entries, defaultColor: m.color),
|
||||
);
|
||||
}
|
||||
|
||||
/// A driven-in image card (T-249): the image rendered inline, clide-owned
|
||||
/// (Flutter's [Image.file], no third-party viewer), display-only per D-78.
|
||||
/// Bounded so a large image scales down to the pane width and never pushes
|
||||
@@ -718,6 +757,11 @@ class _ConversationTurn extends StatelessWidget {
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Annotation title above the image (T-316), when a --file payload set it.
|
||||
if (m.label != null && m.label!.isNotEmpty) ...[
|
||||
ClideText(m.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// The card stays display-only (D-78); the click is a navigation
|
||||
// gesture that opens the full-screen lightbox (T-252), not an inline
|
||||
// control.
|
||||
@@ -739,6 +783,10 @@ class _ConversationTurn extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (m.description != null && m.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(m.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
],
|
||||
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
|
||||
],
|
||||
),
|
||||
@@ -800,10 +848,10 @@ class _ConversationTurn extends StatelessWidget {
|
||||
final outcome = toolUseOutcomes[t.toolUseId];
|
||||
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
||||
final collapser = ClideCollapserCard(
|
||||
label: t.name,
|
||||
label: _toolNameLabel(context, t.name),
|
||||
color: color,
|
||||
collapsedSummary: _toolUseSummary(t),
|
||||
counter: '1 step',
|
||||
counter: _stepsCounter(context, 1),
|
||||
status: _toolRunStatus(t),
|
||||
children: [_toolContentCard(context, t)],
|
||||
);
|
||||
@@ -818,8 +866,8 @@ class _ConversationTurn extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: ClideCollapserCard(
|
||||
label: ClideSettings.i18n.string(context, 'conversation.label.agentRun', namespace: 'builtin.claude', placeholder: 'agent run'),
|
||||
collapsedSummary: _summarizeActivity(runItems.last),
|
||||
counter: runItems.length == 1 ? '1 step' : '${runItems.length} steps',
|
||||
collapsedSummary: _summarizeActivity(context, runItems.last),
|
||||
counter: _stepsCounter(context, runItems.length),
|
||||
children: [
|
||||
for (final r in runItems)
|
||||
_ConversationTurn(
|
||||
@@ -849,7 +897,18 @@ class _ConversationTurn extends StatelessWidget {
|
||||
Widget _workflowCard(BuildContext context, AssistantToolUse t, WorkflowRun run) {
|
||||
final title = run.name ?? ClideSettings.i18n.string(context, 'conversation.label.workflow', namespace: 'builtin.claude', placeholder: 'workflow');
|
||||
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
|
||||
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
|
||||
final counter = run.agentCount == 0
|
||||
? ClideSettings.i18n.string(context, 'conversation.counter.starting', namespace: 'builtin.claude', placeholder: 'starting')
|
||||
: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'conversation.counter.agents',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: '${run.doneCount}/${run.agentCount} agents',
|
||||
replacers: [
|
||||
I18nReplacer(from: '{done}', replace: '${run.doneCount}'),
|
||||
I18nReplacer(from: '{total}', replace: '${run.agentCount}'),
|
||||
],
|
||||
);
|
||||
final detail = run.done ? (run.summary ?? run.description) : run.description;
|
||||
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
|
||||
return ClideCollapserCard(
|
||||
@@ -990,7 +1049,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: accent,
|
||||
borderColor: outcome == null ? null : accent,
|
||||
label: t.name,
|
||||
label: _toolNameLabel(context, t.name),
|
||||
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
status: status,
|
||||
body: toolInputBody(context, tokens, t.name, t.input, mono),
|
||||
@@ -1047,7 +1106,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: quiet ? tokens.globalTextMuted : accent,
|
||||
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
|
||||
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
|
||||
label: paired != null ? '${_toolNameLabel(context, paired.name)} · $errLabel' : errLabel,
|
||||
copyText: t.content,
|
||||
collapsible: quiet || multiline,
|
||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||
@@ -1061,7 +1120,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
// For Write/Edit, the result is usually "OK" — keep it as plain text.
|
||||
final multiline = t.content.contains('\n');
|
||||
final isOutputTool = paired != null && const {'Bash', 'Read', 'Grep', 'LS'}.contains(paired.name);
|
||||
final resultLabel = paired != null ? '${paired.name} · $label' : label;
|
||||
final resultLabel = paired != null ? '${_toolNameLabel(context, paired.name)} · $label' : label;
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: accent,
|
||||
@@ -1127,8 +1186,8 @@ class _ActivityCard extends StatelessWidget {
|
||||
final count = items.length;
|
||||
return ClideCollapserCard(
|
||||
label: ClideSettings.i18n.string(context, 'conversation.cluster.activity', namespace: 'builtin.claude', placeholder: 'Activity'),
|
||||
collapsedSummary: _summarizeActivity(items.last),
|
||||
counter: count == 1 ? '1 step' : '$count steps',
|
||||
collapsedSummary: _summarizeActivity(context, items.last),
|
||||
counter: _stepsCounter(context, count),
|
||||
status: _runStatus(items, resultByToolUseId),
|
||||
children: [
|
||||
for (final item in items)
|
||||
@@ -1150,6 +1209,36 @@ class _ActivityCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized display label for a tool name (T-462). File/web/task operations
|
||||
/// have natural translations; command/proper-name tools (Bash, Grep, Glob,
|
||||
/// ScheduleWakeup, MCP tools, …) have no catalog key by design and fall back to
|
||||
/// the raw name — so `warnIfMissing: false` keeps a miss from logging (T-493).
|
||||
String _toolNameLabel(BuildContext context, String name) =>
|
||||
ClideSettings.i18n.string(context, 'tool.name.$name', namespace: 'builtin.claude', placeholder: name, warnIfMissing: false);
|
||||
|
||||
/// Localized "N steps" counter for a collapser header (T-462). Singular and
|
||||
/// plural are distinct catalog keys; the English forms double as the fallback.
|
||||
String _stepsCounter(BuildContext context, int n) => n == 1
|
||||
? ClideSettings.i18n.string(context, 'conversation.counter.step', namespace: 'builtin.claude', placeholder: '1 step')
|
||||
: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'conversation.counter.steps',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: '$n steps',
|
||||
replacers: [I18nReplacer(from: '{count}', replace: '$n')],
|
||||
);
|
||||
|
||||
/// Localized "N edits" counter for the edit-run collapser header (T-462).
|
||||
String _editsCounter(BuildContext context, int n) => n == 1
|
||||
? ClideSettings.i18n.string(context, 'conversation.counter.edit', namespace: 'builtin.claude', placeholder: '1 edit')
|
||||
: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'conversation.counter.edits',
|
||||
namespace: 'builtin.claude',
|
||||
placeholder: '$n edits',
|
||||
replacers: [I18nReplacer(from: '{count}', replace: '$n')],
|
||||
);
|
||||
|
||||
/// Aggregate live status for a run's header tick (T-296): error if any tool in
|
||||
/// the run failed, else running while its last tool awaits a result, else
|
||||
/// success. Null (no tools) shows no indicator.
|
||||
@@ -1193,8 +1282,8 @@ class _EditRunCard extends StatelessWidget {
|
||||
final count = edits.length;
|
||||
return ClideCollapserCard(
|
||||
label: ClideSettings.i18n.string(context, 'conversation.cluster.edits', namespace: 'builtin.claude', placeholder: 'Edits'),
|
||||
collapsedSummary: _summarizeActivity(edits.last),
|
||||
counter: count == 1 ? '1 edit' : '$count edits',
|
||||
collapsedSummary: _summarizeActivity(context, edits.last),
|
||||
counter: _editsCounter(context, count),
|
||||
status: _runStatus(edits, resultByToolUseId),
|
||||
children: [
|
||||
for (final item in edits)
|
||||
@@ -1216,22 +1305,125 @@ class _EditRunCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// One-line summary of a folded item for the collapsed ticker.
|
||||
String _summarizeActivity(ConversationItem item) {
|
||||
String _summarizeActivity(BuildContext context, ConversationItem item) {
|
||||
String label(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.claude', placeholder: fallback);
|
||||
switch (item) {
|
||||
case AssistantToolUse(:final name, :final input):
|
||||
final raw = input['command'] ?? input['file_path'] ?? input['path'] ?? input['pattern'] ?? input['url'];
|
||||
final detail = raw is String ? raw.split('\n').first.trim() : '';
|
||||
final clipped = detail.length > 72 ? '${detail.substring(0, 72)}…' : detail;
|
||||
return clipped.isEmpty ? name : '$name $clipped';
|
||||
final toolName = _toolNameLabel(context, name);
|
||||
return clipped.isEmpty ? toolName : '$toolName $clipped';
|
||||
case ToolResultMessage(:final isError):
|
||||
return isError ? '↳ result · error' : '↳ result';
|
||||
final result = label('conversation.label.result', 'result');
|
||||
return isError ? '↳ $result · ${label('conversation.label.error', 'error')}' : '↳ $result';
|
||||
case AssistantThinkingMessage():
|
||||
return 'thinking…';
|
||||
return '${label('conversation.label.thinking', 'thinking')}…';
|
||||
case UserMessage(:final text):
|
||||
return text;
|
||||
case AssistantTextMessage(:final text):
|
||||
return text;
|
||||
case ImageMessage(:final path):
|
||||
return 'image $path';
|
||||
return '${label('conversation.label.image', 'image')} $path';
|
||||
case DrawingMessage(label: final cardLabel):
|
||||
return '${label('conversation.label.drawing', 'drawing')}${cardLabel != null ? ' $cardLabel' : ''}';
|
||||
case IconMessage(:final entries):
|
||||
return '${label('conversation.label.icon', 'icons')} ${entries.map((e) => e.name).join(', ')}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode every annotated `<image>` href in [doc] into a `ui.Image` (T-319) —
|
||||
/// the resolver the renderer + lightbox paint through. A missing/undecodable
|
||||
/// file is skipped (its cell paints empty), never thrown. [load] (read bytes)
|
||||
/// and [decode] are injectable so the path is testable off the real filesystem.
|
||||
Future<Map<String, ui.Image>> loadDrawingImages(
|
||||
SvgDocument doc, {
|
||||
Future<Uint8List?> Function(String path)? load,
|
||||
Future<ui.Image> Function(Uint8List bytes)? decode,
|
||||
}) async {
|
||||
final reader = load ?? _readFileBytes;
|
||||
final decoder = decode ?? decodeImageFromList;
|
||||
final out = <String, ui.Image>{};
|
||||
for (final href in doc.annotations.map((a) => a.href).whereType<String>().toSet()) {
|
||||
try {
|
||||
final bytes = await reader(href);
|
||||
if (bytes != null) out[href] = await decoder(bytes);
|
||||
} catch (_) {
|
||||
// Missing/undecodable — leave that cell empty.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<Uint8List?> _readFileBytes(String path) async {
|
||||
try {
|
||||
return await File(path).readAsBytes();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a [DrawingCard], loading any `<image>` hrefs the SVG references into
|
||||
/// `ui.Image`s first (T-319) — the renderer + lightbox both paint through the
|
||||
/// resolver. A drawing with no images (d2, raw svg) loads nothing and renders
|
||||
/// immediately; a missing/unreadable file is skipped (that cell paints empty).
|
||||
class _DrawingWithImages extends StatefulWidget {
|
||||
const _DrawingWithImages({required this.doc, this.label, this.description, this.source, this.sourceLabel});
|
||||
|
||||
final SvgDocument doc;
|
||||
final String? label, description, source, sourceLabel;
|
||||
|
||||
@override
|
||||
State<_DrawingWithImages> createState() => _DrawingWithImagesState();
|
||||
}
|
||||
|
||||
class _DrawingWithImagesState extends State<_DrawingWithImages> {
|
||||
final Map<String, ui.Image> _images = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final imgs = await loadDrawingImages(widget.doc);
|
||||
if (imgs.isEmpty) return;
|
||||
if (!mounted) {
|
||||
for (final img in imgs.values) {
|
||||
img.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() => _images.addAll(imgs));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final img in _images.values) {
|
||||
img.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resolver = _images.isEmpty ? null : (String href) => _images[href];
|
||||
return DrawingCard(
|
||||
document: widget.doc,
|
||||
images: resolver,
|
||||
label: widget.label,
|
||||
description: widget.description,
|
||||
source: widget.source,
|
||||
sourceLabel: widget.sourceLabel,
|
||||
// A data-lightbox element opens the whole drawing, zoomable (T-318); the
|
||||
// lightbox paints through the same image resolver (T-319).
|
||||
onLightbox: () => ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: SvgView(document: widget.doc, images: resolver),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/builtin/claude/src/account_login_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart';
|
||||
import 'package:clide/builtin/claude/src/account_settings_control.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
@@ -15,7 +19,11 @@ import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
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/builtin/claude/src/transcript_reader.dart' show DrawingMessage, IconEntry, IconMessage, ImageMessage;
|
||||
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
|
||||
import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel;
|
||||
import 'package:clide/src/daemon/draw_commands.dart' show drawShowChannel;
|
||||
import 'package:clide/src/daemon/icon_commands.dart' show iconShowChannel;
|
||||
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart';
|
||||
@@ -44,6 +52,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get version => '0.2.0';
|
||||
@override
|
||||
// No runtime dependency on builtin.terminal: the login pane (T-485) reuses the
|
||||
// TerminalPane *widget* (a code import), which spawns via the always-present
|
||||
// pane.spawn IPC — it doesn't need the terminal extension activated.
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
@@ -196,9 +207,41 @@ class ClaudeExtension extends ClideExtension {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Per-repo Claude account (T-482, epic T-476). The dropdown binds this
|
||||
// workspace to a registered account; manage the registry itself with
|
||||
// the `clide claude account` verbs (T-480).
|
||||
SettingsSection(
|
||||
label: 'Account',
|
||||
labelKey: 'settings.claude.account.label',
|
||||
fields: [
|
||||
SettingsField(
|
||||
// Placeholder keys — custom fields are rendered by their control,
|
||||
// never stored here; kept clear of the app.claude.account.<hash>
|
||||
// binding namespace the registry scans (T-480).
|
||||
key: 'app.claude.accountsRegistry',
|
||||
kind: SettingsFieldKind.custom,
|
||||
label: 'Accounts',
|
||||
labelKey: 'settings.claude.account.registry.label',
|
||||
help: 'Registered Claude accounts (each a separate config dir + login).',
|
||||
helpKey: 'settings.claude.account.registry.help',
|
||||
customId: 'claude.accounts',
|
||||
),
|
||||
SettingsField(
|
||||
key: 'app.claude.workspaceAccount',
|
||||
kind: SettingsFieldKind.custom,
|
||||
label: 'Account for this workspace',
|
||||
labelKey: 'settings.claude.account.workspace.label',
|
||||
help: 'Which Claude account this repo runs under; Default uses the system login.',
|
||||
helpKey: 'settings.claude.account.workspace.help',
|
||||
customId: 'claude.workspace-account',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsControlContribution(id: 'claude.accounts', customId: 'claude.accounts', builder: (_) => const ClaudeAccountsListControl()),
|
||||
SettingsControlContribution(id: 'claude.workspace-account', customId: 'claude.workspace-account', builder: (_) => const ClaudeWorkspaceAccountControl()),
|
||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.show <sessionId>
|
||||
CommandContribution(
|
||||
@@ -467,8 +510,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
|
||||
// The clide-managed session set (T-169). Panes spawn/bind through it so a
|
||||
// session outlives its pane and is shared across surfaces.
|
||||
_orchestrator = ClaudeSessionOrchestrator();
|
||||
// session outlives its pane and is shared across surfaces. The account
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude account.
|
||||
_orchestrator = ClaudeSessionOrchestrator(accountRegistry: AccountRegistry(ctx.settings));
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
@@ -482,9 +526,73 @@ class ClaudeExtension extends ClideExtension {
|
||||
// user is looking at (the primary lead, else the first visible session).
|
||||
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
|
||||
|
||||
// `clide draw --file <doc>` (T-318): the dispatcher lowers the doc to SVG
|
||||
// and publishes a 'draw' message; we inject the drawing card into the
|
||||
// conversation the user is looking at.
|
||||
_subs.add(ctx.messages.subscribe(channel: drawShowChannel).listen(_onDrawShow));
|
||||
|
||||
// `clide icon show <name…>` (T-313): the dispatcher resolves the glyphs and
|
||||
// publishes an 'icon' message; we inject the glyph card.
|
||||
_subs.add(ctx.messages.subscribe(channel: iconShowChannel).listen(_onIconShow));
|
||||
|
||||
// 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));
|
||||
|
||||
// `clide claude account set/unset/remove --purge` (T-480): the dispatcher
|
||||
// writes the registry then publishes here; only the UI layer can respawn
|
||||
// the workspace's panes onto the newly-bound account or delete a config dir.
|
||||
_subs.add(ctx.messages.subscribe(channel: accountActionChannel).listen(_onAccountAction));
|
||||
|
||||
// A freshly-created project (T-488) announces itself once it's open; show the
|
||||
// per-repo account roadblock so the user binds it now (existing opens, which
|
||||
// never announce, are never prompted).
|
||||
_subs.add(ctx.messages.subscribe(channel: projectCreatedChannel).listen(_onProjectCreated));
|
||||
}
|
||||
|
||||
void _onProjectCreated(Message m) {
|
||||
final dir = m.data['dir'] as String?;
|
||||
final ctx = _ctx;
|
||||
if (dir == null || ctx == null) return;
|
||||
final name = dir.split('/').where((s) => s.isNotEmpty).lastOrNull ?? dir;
|
||||
ctx.dialog.show<Object>((c, dismiss) => ClaudeAccountRoadblockDialog(projectName: name, onClose: dismiss));
|
||||
}
|
||||
|
||||
/// Side-effects for the `claude account` verbs (T-480). The dispatcher does
|
||||
/// the registry write and publishes the action here; respawning panes,
|
||||
/// deleting a config dir, and (future) the login terminal pane are UI-layer
|
||||
/// concerns the Flutter-free handler can't do itself.
|
||||
void _onAccountAction(Message m) {
|
||||
switch (m.data['action'] as String?) {
|
||||
case 'set':
|
||||
case 'unset':
|
||||
final cwd = m.data['cwd'] as String?;
|
||||
final orch = _orchestrator;
|
||||
if (cwd != null && orch != null) unawaited(orch.respawnForWorkspace(cwd));
|
||||
case 'purge':
|
||||
final dir = m.data['dir'] as String?;
|
||||
if (dir != null) unawaited(_purgeAccountDir(dir));
|
||||
case 'login':
|
||||
final name = m.data['name'] as String?;
|
||||
final dir = m.data['dir'] as String?;
|
||||
final ctx = _ctx;
|
||||
// Host `CLAUDE_CONFIG_DIR=<dir> claude login` in a modal terminal pane
|
||||
// (T-485); the CLI owns the OAuth browser flow.
|
||||
if (name != null && dir != null && ctx != null) {
|
||||
ctx.dialog.show<Object>((c, dismiss) => ClaudeLoginDialog(name: name, dir: dir, cwd: _projectRoot, onClose: dismiss));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a purged account's config dir (`remove --purge`). Guarded: only a
|
||||
/// `~/.claude-*` directory that is a direct child of the user's home is ever
|
||||
/// removed — never an arbitrary path, even though the dir came from our own
|
||||
/// registry. A `rm -rf` of the wrong dir is unrecoverable.
|
||||
Future<void> _purgeAccountDir(String dir) async {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null || !isPurgeableAccountDir(dir, home)) return;
|
||||
final d = Directory(dir);
|
||||
if (await d.exists()) await d.delete(recursive: true);
|
||||
}
|
||||
|
||||
/// Hand a picked-up ticket to the active Claude session (T-327/T-339). The
|
||||
@@ -538,6 +646,62 @@ class ClaudeExtension extends ClideExtension {
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
label: m.data['label'] as String?,
|
||||
description: m.data['description'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inject an [IconMessage] from a published `icon` bus message (T-313).
|
||||
void _onIconShow(Message m) {
|
||||
final raw = m.data['entries'];
|
||||
if (raw is! List || raw.isEmpty) return;
|
||||
final entries = <IconEntry>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final cp = item['codepoint'];
|
||||
if (cp is! int) continue;
|
||||
entries.add(
|
||||
IconEntry(
|
||||
codepoint: cp,
|
||||
name: item['name'] as String? ?? '',
|
||||
label: item['label'] as String?,
|
||||
description: item['description'] as String?,
|
||||
color: item['color'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entries.isEmpty) return;
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(
|
||||
IconMessage(
|
||||
uuid: 'icon-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
entries: entries,
|
||||
color: m.data['color'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inject a [DrawingMessage] from a published `draw` bus message (T-318).
|
||||
/// Dropped silently if no live conversation is available — the CLI already
|
||||
/// reported success at publish time, and a missing pane is transient.
|
||||
void _onDrawShow(Message m) {
|
||||
final svg = m.data['svg'] as String?;
|
||||
if (svg == null || svg.isEmpty) return;
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(
|
||||
DrawingMessage(
|
||||
uuid: 'draw-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
svg: svg,
|
||||
label: m.data['label'] as String?,
|
||||
description: m.data['description'] as String?,
|
||||
source: m.data['source'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/// The Phosphor glyph card (T-313) — display-only per D-78.
|
||||
///
|
||||
/// Each entry shows a HERO glyph (legible detail) plus a continuous sample strip
|
||||
/// at the real UI sizes (10–48), so a reviewer judges how the glyph reads where
|
||||
/// the app actually uses it; the optional per-entry label + description turn the
|
||||
/// card into a labelled offer the interaction zone can mirror as a choice list.
|
||||
/// A per-entry or card-level `color` (hex or CSS name) tints the glyph — content
|
||||
/// color, not a clide token (the glyph is for whatever project we're on); it
|
||||
/// falls back to the card foreground.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show IconEntry;
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/svg/svg_color.dart' show parseSvgColor;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class IconGlyphCard extends StatelessWidget {
|
||||
const IconGlyphCard({super.key, required this.entries, this.defaultColor});
|
||||
|
||||
final List<IconEntry> entries;
|
||||
|
||||
/// Card-level default glyph color (hex / CSS name), applied to entries without
|
||||
/// their own.
|
||||
final String? defaultColor;
|
||||
|
||||
/// One continuous sample strip, smallest → largest (T-313, finalized set).
|
||||
static const _sizes = <double>[10, 11, 12, 13, 14, 15, 18, 20, 24, 32, 48];
|
||||
static const _hero = 52.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final cardColor = _parse(defaultColor) ?? tokens.globalForeground;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < entries.length; i++) ...[if (i > 0) const SizedBox(height: 18), _entry(tokens, entries[i], cardColor)],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entry(SurfaceTokens tokens, IconEntry e, Color cardColor) {
|
||||
final color = _parse(e.color) ?? cardColor;
|
||||
final painter = PhosphorIconPainter(e.codepoint);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (e.label != null && e.label!.isNotEmpty) ClideText(e.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground),
|
||||
if (e.description != null && e.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: ClideText(e.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ClideIcon(painter, size: _hero, color: color),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [for (final s in _sizes) _sample(tokens, painter, color, s)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sample(SurfaceTokens tokens, PhosphorIconPainter painter, Color color, double size) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(painter, size: size, color: color),
|
||||
const SizedBox(height: 2),
|
||||
ClideText('${size.toInt()}', fontSize: clideFontBadge, color: tokens.globalTextMuted),
|
||||
],
|
||||
);
|
||||
|
||||
Color? _parse(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final argb = parseSvgColor(raw);
|
||||
return argb == null ? null : Color(argb);
|
||||
}
|
||||
}
|
||||
@@ -87,59 +87,61 @@ class ActivityTabView extends StatelessWidget {
|
||||
children: [
|
||||
// SESSION control strip (T-415): drives the primary session through
|
||||
// the builtin.claude/command bus — identical to typing the command.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION'),
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
// Carded to match the settings overlay (T-158 facelift).
|
||||
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION')),
|
||||
metaCard(tokens, [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'),
|
||||
'trash',
|
||||
'/clear',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'),
|
||||
'arrows-in-simple',
|
||||
'/compact',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'),
|
||||
'git-branch',
|
||||
'/fork',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'),
|
||||
'clock-counter-clockwise',
|
||||
'/resume',
|
||||
),
|
||||
const Spacer(),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'),
|
||||
'arrow-clockwise',
|
||||
'/usage',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'),
|
||||
'trash',
|
||||
'/clear',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'),
|
||||
'arrows-in-simple',
|
||||
'/compact',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'),
|
||||
'git-branch',
|
||||
'/fork',
|
||||
),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'),
|
||||
'clock-counter-clockwise',
|
||||
'/resume',
|
||||
),
|
||||
const Spacer(),
|
||||
_control(
|
||||
context,
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'),
|
||||
'arrow-clockwise',
|
||||
'/usage',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
]),
|
||||
if (sections.isNotEmpty) const SizedBox(height: 16),
|
||||
if (sections.isEmpty)
|
||||
metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.'))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.')),
|
||||
)
|
||||
else
|
||||
...metaTableChildren(tokens, sections),
|
||||
...metaTableChildren(context, tokens, sections),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,43 +53,40 @@ class ConfigTabView extends StatelessWidget {
|
||||
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS control panel — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS'),
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
// Pinned SETTINGS control panel — not collapsible. Carded to match the
|
||||
// settings overlay (T-158 facelift).
|
||||
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS')),
|
||||
metaCard(tokens, [
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'),
|
||||
value: model,
|
||||
valueColor: tokens.globalFocus,
|
||||
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||
command: 'model',
|
||||
),
|
||||
),
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'),
|
||||
value: model,
|
||||
valueColor: tokens.globalFocus,
|
||||
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||
command: 'model',
|
||||
),
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'),
|
||||
value: effort,
|
||||
options: kEffortLevels,
|
||||
isActive: (o) => o.value == effort,
|
||||
command: 'effort',
|
||||
),
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'),
|
||||
value: permissionModeLabel(mode),
|
||||
options: kPermissionModes,
|
||||
isActive: (o) => o.value == mode,
|
||||
command: 'permissions',
|
||||
),
|
||||
_configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle),
|
||||
_configRow(
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'),
|
||||
ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'),
|
||||
),
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'),
|
||||
value: effort,
|
||||
options: kEffortLevels,
|
||||
isActive: (o) => o.value == effort,
|
||||
command: 'effort',
|
||||
),
|
||||
SettingControlRow(
|
||||
label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'),
|
||||
value: permissionModeLabel(mode),
|
||||
options: kPermissionModes,
|
||||
isActive: (o) => o.value == mode,
|
||||
command: 'permissions',
|
||||
),
|
||||
_configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle),
|
||||
_configRow(
|
||||
tokens,
|
||||
ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'),
|
||||
ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||
@@ -116,7 +113,7 @@ class ConfigTabView extends StatelessWidget {
|
||||
/// One read-only key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -324,7 +321,7 @@ class _SettingControlRowState extends State<SettingControlRow> {
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -43,41 +43,64 @@ Widget metaPlaceholder(String text) => Padding(
|
||||
child: ClideText(text, muted: true, fontSize: kMetaFont),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
|
||||
/// Small-caps section header (mono, uppercase) — the same treatment the settings
|
||||
/// overlay uses (settings_category_view `_SectionCard`), so the Claude meta
|
||||
/// sidebar and the settings modal read as one card system (T-158 facelift).
|
||||
Widget metaSectionHeader(BuildContext context, SurfaceTokens tokens, String label) => Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: ClideText(label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
|
||||
/// The table rows without the enclosing ListView, for tabs that compose extra
|
||||
/// widgets around the sections (the Activity tab's control strip, T-415).
|
||||
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
/// An elevated card (the settings card surface) wrapping divider-separated
|
||||
/// [rows]: `panelHeader` fill, `dividerColor` hairline border, 6px radius.
|
||||
Widget metaCard(SurfaceTokens tokens, List<Widget> rows) => ClideSurface(
|
||||
color: tokens.panelHeader,
|
||||
border: tokens.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < rows.length; i++) ...[if (i > 0) const ClideDivider(), rows[i]],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// One label→value row sized for the card interior — the shared label column
|
||||
/// then the value, uniform with the settings field rows.
|
||||
Widget metaCardRow(SurfaceTokens tokens, MetaRow r) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Key→value sections rendered as carded blocks (Activity + Config).
|
||||
Widget buildMetaTable(BuildContext context, SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(context, tokens, sections));
|
||||
|
||||
/// The carded sections without the enclosing ListView, for tabs that compose
|
||||
/// extra widgets around them (the Activity tab's control strip, T-415): a
|
||||
/// small-caps header above an elevated card of label→value rows.
|
||||
List<Widget> metaTableChildren(BuildContext context, SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 16),
|
||||
child: metaSectionHeader(context, tokens, s.header),
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
children.add(metaCard(tokens, [for (final r in s.rows) metaCardRow(tokens, r)]));
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
/// Stateless and props-driven; the parent owns the member list, inject
|
||||
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
///
|
||||
/// The account `/usage` budget is deliberately NOT shown here: it is
|
||||
/// per-account (every team session shares one `~/.claude` login), so it can't
|
||||
/// be split per member — it lives once on the Activity tab, next to the
|
||||
/// refresh control that fetches it (T-158).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
@@ -89,12 +94,7 @@ class TeamTabView extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS'),
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS')),
|
||||
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -90,17 +90,22 @@ class _RunningIndicatorState extends State<RunningIndicator> with SingleTickerPr
|
||||
label: ClideSettings.i18n.string(context, 'running.semantics', namespace: 'builtin.claude', placeholder: 'Claude is running'),
|
||||
child: ExcludeSemantics(
|
||||
child: reduced
|
||||
? ClideText('${_verbs.first}…', color: claudeAccent, fontSize: clideFontMeta)
|
||||
? ClideText('${_verb(context, _verbs.first)}…', color: claudeAccent, fontSize: clideFontMeta)
|
||||
: AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (ctx, _) {
|
||||
final elapsed = _c.value * _periodSeconds;
|
||||
final dots = '.' * (elapsed.floor() % 4);
|
||||
final word = _verbs[(elapsed ~/ _secondsPerWord) % _verbs.length];
|
||||
return ClideText('$word$dots', color: claudeAccent, fontSize: clideFontMeta);
|
||||
return ClideText('${_verb(ctx, word)}$dots', color: claudeAccent, fontSize: clideFontMeta);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Localize a verb through the catalog; the English word doubles as the key
|
||||
/// suffix and the fallback, so a missing translation degrades to English.
|
||||
String _verb(BuildContext context, String word) =>
|
||||
ClideSettings.i18n.string(context, 'running.verb.${word.toLowerCase()}', namespace: 'builtin.claude', placeholder: word);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/builtin/claude/src/agent_bootstrap.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
@@ -152,10 +153,15 @@ class ManagedSession {
|
||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
/// Per-repo Claude account bindings (epic T-476). When a workspace is bound,
|
||||
/// its hosted sessions spawn under that account's CLAUDE_CONFIG_DIR (T-484).
|
||||
/// Null in tests / when no registry is wired → no injection.
|
||||
final AccountRegistry? accountRegistry;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
@@ -238,13 +244,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
// note and the team preamble merge into ONE --append-system-prompt (claude
|
||||
// honours a single one).
|
||||
final preambles = <String>[clideContextNote(spec.cwd)];
|
||||
// Nudge a FRESH session to reach for the bundled skills (T-490). A new tab
|
||||
// and the post-/clear respawn spawn with resume:false; the account-change
|
||||
// respawn (T-480) and real resumes carry prior context (resume:true), and a
|
||||
// fork inherits its source — none of those are re-nagged.
|
||||
if (!spec.resume && !spec.isFork) preambles.add(clideSkillsNote());
|
||||
if (spec.team) {
|
||||
final name = spec.memberName ?? spec.role;
|
||||
broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role));
|
||||
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
@@ -312,6 +323,31 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Respawn the workspace's solo sessions in place so they pick up a changed
|
||||
/// per-repo Claude account (T-480). Each is closed (awaits real process
|
||||
/// death, T-437) then re-spawned on the SAME id with `--resume` of its real
|
||||
/// session id, so the conversation continues under the newly-bound
|
||||
/// `CLAUDE_CONFIG_DIR` (resolved at spawn time by [agentBootstrap] from the
|
||||
/// [accountRegistry]). Team / forked sessions are skipped — re-joining the
|
||||
/// broker or re-forking on an account swap is out of scope; they adopt the
|
||||
/// new account on their next natural spawn.
|
||||
Future<void> respawnForWorkspace(String cwd) async {
|
||||
final targets = _sessions.values.where((s) => s.cwd == cwd && s.memberName == null && s.forkSourceSessionId == null).toList();
|
||||
for (final s in targets) {
|
||||
final spec = SpawnSpec(
|
||||
id: s.id,
|
||||
role: s.role,
|
||||
sessionId: s.sessionId,
|
||||
cwd: s.cwd,
|
||||
resume: true,
|
||||
transcriptPath: claudeTranscriptPath(s.cwd, s.sessionId),
|
||||
visible: s.visible,
|
||||
);
|
||||
await close(s.id);
|
||||
await spawn(spec);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill and forget a session (the real teardown). The conversation's
|
||||
/// onDispose kills the process + closes its streams; we then AWAIT the
|
||||
/// session's teardown so the `claude` process is genuinely dead before we
|
||||
|
||||
@@ -178,7 +178,15 @@ 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,
|
||||
this.label,
|
||||
this.description,
|
||||
});
|
||||
|
||||
/// Absolute path to the image file on disk.
|
||||
final String path;
|
||||
@@ -186,10 +194,69 @@ final class ImageMessage extends ConversationItem {
|
||||
/// Optional caption shown under the image.
|
||||
final String? caption;
|
||||
|
||||
/// Optional richer annotations from a `--file` metadata payload (T-316): a
|
||||
/// title/label above the image and a longer description beneath it.
|
||||
final String? label, description;
|
||||
|
||||
@override
|
||||
String toString() => 'ImageMessage($path${caption != null ? ', "$caption"' : ''})';
|
||||
}
|
||||
|
||||
/// A locally-injected drawing card (T-318). Not parsed from the transcript —
|
||||
/// driven into the conversation by `clide draw --file <doc>` (D-6 parity) and
|
||||
/// rendered display-only per D-78. [svg] is the SVG substrate the renderer
|
||||
/// paints (already lowered from the doc's template / primitive source);
|
||||
/// [label] / [description] are the optional card caption.
|
||||
final class DrawingMessage extends ConversationItem {
|
||||
const DrawingMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.svg,
|
||||
this.label,
|
||||
this.description,
|
||||
this.source,
|
||||
});
|
||||
|
||||
/// The SVG document source the renderer paints.
|
||||
final String svg;
|
||||
|
||||
/// Optional card-level caption (label + supporting description).
|
||||
final String? label, description;
|
||||
|
||||
/// Optional template source (e.g. the d2 diagram text) — shown in a collapsed
|
||||
/// "view source" disclosure on the card when present (T-494).
|
||||
final String? source;
|
||||
|
||||
@override
|
||||
String toString() => 'DrawingMessage(${label ?? '<svg>'})';
|
||||
}
|
||||
|
||||
/// One glyph entry on an [IconMessage] (T-313): a resolved Phosphor [codepoint]
|
||||
/// (its [name] kept for copy/debug), with optional per-entry [label],
|
||||
/// [description], and [color] (hex or CSS name, parsed at render).
|
||||
final class IconEntry {
|
||||
const IconEntry({required this.codepoint, required this.name, this.label, this.description, this.color});
|
||||
|
||||
final int codepoint;
|
||||
final String name;
|
||||
final String? label, description, color;
|
||||
}
|
||||
|
||||
/// A locally-injected Phosphor glyph card (T-313). Driven by `clide icon show`
|
||||
/// (D-6 parity), display-only per D-78. Renders each [entries] glyph at a hero
|
||||
/// size plus a sample strip of real UI sizes, with its optional label +
|
||||
/// description; [color] is the card-level default glyph color.
|
||||
final class IconMessage extends ConversationItem {
|
||||
const IconMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.entries, this.color});
|
||||
|
||||
final List<IconEntry> entries;
|
||||
final String? color;
|
||||
|
||||
@override
|
||||
String toString() => 'IconMessage(${entries.length} glyph${entries.length == 1 ? '' : 's'})';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,7 @@ class MarkdownExtension extends ClideExtension {
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
StreamSubscription<Message>? _sub;
|
||||
StreamSubscription<DaemonEvent>? _editorSub;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
@@ -38,8 +39,21 @@ class MarkdownExtension extends ClideExtension {
|
||||
if (msg.data['path'] is! String) return;
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||
});
|
||||
// Live-sync read-mirror (T-36, D-50 behavior 4): opening a renderable .md in
|
||||
// the editor auto-reveals the reader, which then mirrors the buffer live.
|
||||
// Non-renderable files reveal nothing (D-50 behavior 5).
|
||||
_editorSub = ctx.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.subsystem != 'editor' || e.kind != 'editor.opened') return;
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && isRenderableMarkdownPath(path)) {
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async => _sub?.cancel();
|
||||
Future<void> deactivate() async {
|
||||
await _sub?.cancel();
|
||||
await _editorSub?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Whether [path] is a markdown file the reader can mirror live (T-36, D-50).
|
||||
/// Non-renderable files get no auto-viewer (D-50 behavior 5).
|
||||
bool isRenderableMarkdownPath(String path) {
|
||||
final p = path.toLowerCase();
|
||||
return p.endsWith('.md') || p.endsWith('.markdown');
|
||||
}
|
||||
|
||||
class MarkdownViewer extends StatefulWidget {
|
||||
const MarkdownViewer({super.key});
|
||||
|
||||
@@ -17,8 +24,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
String? _content;
|
||||
String? _error;
|
||||
StreamSubscription<Message>? _selectionSub;
|
||||
StreamSubscription<DaemonEvent>? _editorSub;
|
||||
ReaderNav? _nav;
|
||||
|
||||
/// Live-sync mirror state (T-36, D-50 behavior 4): when [_mirror] is true the
|
||||
/// view is a read-only reflection of the open editor buffer [_mirrorId] and
|
||||
/// re-reads it on every edit, rather than a disk snapshot.
|
||||
bool _mirror = false;
|
||||
String? _mirrorId;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -31,12 +45,68 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
final path = msg.data['path'] as String?;
|
||||
if (path != null) _loadFile(path);
|
||||
});
|
||||
// Live-sync: mirror the editor buffer as it's typed (T-36).
|
||||
_editorSub = kernel.events.on<DaemonEvent>().listen(_onEditorEvent);
|
||||
// The editor may have opened before this viewer mounted (the extension
|
||||
// reveals the tab on editor.opened) — pick up the active buffer now.
|
||||
unawaited(_mirrorActiveIfRenderable(kernel));
|
||||
// Grab the latest entry the nav already holds (a selection that
|
||||
// revealed this tab before we subscribed).
|
||||
final current = _nav!.current;
|
||||
if (current != null) _loadFile(current);
|
||||
}
|
||||
|
||||
/// On mount, mirror the active editor buffer if it's a renderable file —
|
||||
/// `editor.read` with no id resolves to the active buffer.
|
||||
Future<void> _mirrorActiveIfRenderable(KernelServices kernel) async {
|
||||
final resp = await kernel.ipc.request('editor.read', args: const {});
|
||||
if (!mounted || !resp.ok) return;
|
||||
final path = resp.data['path'] as String?;
|
||||
final id = resp.data['id'] as String?;
|
||||
if (path != null && id != null && isRenderableMarkdownPath(path)) {
|
||||
_enterMirror(id, path, resp.data['content'] as String? ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
void _onEditorEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'editor') return;
|
||||
final id = e.data['id'] as String?;
|
||||
final path = e.data['path'] as String?;
|
||||
switch (e.kind) {
|
||||
case 'editor.opened':
|
||||
if (id != null && path != null && isRenderableMarkdownPath(path)) _enterMirror(id, path, e.data['content'] as String? ?? '');
|
||||
case 'editor.active-changed':
|
||||
// Followed the active buffer: mirror a renderable one, drop the mirror
|
||||
// for a non-renderable / no buffer (D-50 behavior 5 — no auto-viewer).
|
||||
if (id != null && path != null && isRenderableMarkdownPath(path)) {
|
||||
unawaited(_reread(id, path));
|
||||
} else {
|
||||
if (_mirror) setState(() => _mirror = false);
|
||||
}
|
||||
case 'editor.edited':
|
||||
if (_mirror && id != null && id == _mirrorId && _path != null) unawaited(_reread(id, _path!));
|
||||
case 'editor.closed':
|
||||
if (id == _mirrorId && _mirror) setState(() => _mirror = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the in-memory buffer [id] and refresh the mirror (live edits).
|
||||
Future<void> _reread(String id, String path) async {
|
||||
final resp = await ClideKernel.of(context).ipc.request('editor.read', args: {'id': id});
|
||||
if (!mounted || !resp.ok) return;
|
||||
_enterMirror(id, path, resp.data['content'] as String? ?? '');
|
||||
}
|
||||
|
||||
void _enterMirror(String id, String path, String content) {
|
||||
setState(() {
|
||||
_mirror = true;
|
||||
_mirrorId = id;
|
||||
_path = path;
|
||||
_content = content;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onNavChanged() {
|
||||
if (mounted) setState(() {}); // refresh action-bar button state
|
||||
}
|
||||
@@ -44,11 +114,14 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
@override
|
||||
void dispose() {
|
||||
_selectionSub?.cancel();
|
||||
_editorSub?.cancel();
|
||||
_nav?.removeListener(_onNavChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fetch + display [path]. History lives in [ReaderNav]; never pushes.
|
||||
/// Fetch + display [path] from disk. An explicit (agent-driven) selection, so
|
||||
/// it leaves mirror mode — the edit affordance returns. History lives in
|
||||
/// [ReaderNav]; never pushes.
|
||||
Future<void> _loadFile(String path) async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||
@@ -56,6 +129,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
if (resp.ok) {
|
||||
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
|
||||
setState(() {
|
||||
_mirror = false;
|
||||
_path = path;
|
||||
_content = resp.data['content'] as String? ?? '';
|
||||
_error = null;
|
||||
@@ -121,7 +195,9 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
|
||||
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
|
||||
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
|
||||
onEdit: _path != null ? _onEdit : null,
|
||||
// Read-only while mirroring the live buffer (T-36): the file is already
|
||||
// open in the editor, so no edit affordance here.
|
||||
onEdit: (!_mirror && _path != null) ? _onEdit : null,
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'licenses_loader.dart';
|
||||
import 'update_check.dart';
|
||||
|
||||
/// The Help → About dialog (T-48): clide identity + build info, plus the
|
||||
/// bundled-dependency licenses parsed from `assets/licenses.yaml`.
|
||||
class AboutDialog extends StatelessWidget {
|
||||
const AboutDialog({super.key, required this.onDismiss});
|
||||
const AboutDialog({super.key, required this.onDismiss, this.updateFetch});
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
/// Injected fetch for the update check, so widget tests never touch the
|
||||
/// network (T-47 P1). Production passes null → the real [githubGet].
|
||||
@visibleForTesting
|
||||
final GithubFetch? updateFetch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
@@ -52,6 +60,8 @@ class AboutDialog extends StatelessWidget {
|
||||
value: clideRepository,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_UpdateCheckRow(tokens: tokens, fetch: updateFetch),
|
||||
const SizedBox(height: 16),
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(context, 'licenses.heading', namespace: 'builtin.menubar', placeholder: 'Bundled dependencies'),
|
||||
@@ -76,6 +86,89 @@ class AboutDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Check for updates" button + inline status (T-47 P1). The check runs only on
|
||||
/// this explicit tap — never on launch, never on a timer (D-64 / POLICY.md).
|
||||
class _UpdateCheckRow extends StatefulWidget {
|
||||
const _UpdateCheckRow({required this.tokens, this.fetch});
|
||||
final SurfaceTokens tokens;
|
||||
final GithubFetch? fetch;
|
||||
|
||||
@override
|
||||
State<_UpdateCheckRow> createState() => _UpdateCheckRowState();
|
||||
}
|
||||
|
||||
class _UpdateCheckRowState extends State<_UpdateCheckRow> {
|
||||
UpdateCheckResult? _result;
|
||||
bool _checking = false;
|
||||
|
||||
Future<void> _check() async {
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_result = null;
|
||||
});
|
||||
final r = await checkForUpdate(repositoryUrl: clideRepository, currentVersion: clideVersion, fetch: widget.fetch ?? githubGet);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_result = r;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.menubar', placeholder: fallback);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
ClideButton(label: _t('about.checkUpdates', 'Check for updates'), onPressed: _checking ? null : _check),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _status(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _status(BuildContext context) {
|
||||
final tokens = widget.tokens;
|
||||
if (_checking) return ClideText(_t('about.checking', 'Checking…'), fontSize: 12, color: tokens.globalTextMuted);
|
||||
switch (_result) {
|
||||
case null:
|
||||
return const SizedBox.shrink();
|
||||
case UpdateUpToDate():
|
||||
return ClideText(_t('about.upToDate', "You're on the latest version."), fontSize: 12, color: tokens.globalTextMuted);
|
||||
case UpdateAvailable(:final latest, :final url):
|
||||
return Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'clide $latest available — release notes',
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => unawaited(ClideKernel.of(context).os.openURL(url)),
|
||||
builder: (ctx, hovered, _) => ClideText(
|
||||
ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'about.updateAvailable',
|
||||
namespace: 'builtin.menubar',
|
||||
placeholder: 'clide {version} is available — release notes',
|
||||
replacers: [I18nReplacer(from: '{version}', replace: latest)],
|
||||
),
|
||||
fontSize: 12,
|
||||
color: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
);
|
||||
case UpdateCheckFailed(:final message):
|
||||
return ClideText(
|
||||
'${_t('about.updateFailed', "Couldn't check for updates")} ($message)',
|
||||
fontSize: 12,
|
||||
color: tokens.statusError,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label/value row in the build-info block.
|
||||
class _Kv extends StatelessWidget {
|
||||
const _Kv({required this.label, required this.value, required this.tokens});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/// Manual "check for updates" logic for the About dialog (T-47 P1, story T-46).
|
||||
///
|
||||
/// POLICY-sensitive: this is clide's ONLY outbound HTTP call, and it runs ONLY
|
||||
/// on explicit user action (the About-box button) — never on a launch path, never
|
||||
/// on a timer. It sends NO data about the user (a plain GET to the GitHub Releases
|
||||
/// API), so it doesn't offend D-64's no-telemetry commitment. A background/periodic
|
||||
/// poll would need a deliberate D-64 amendment first and is deferred.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Injectable GET → response body (throws on failure). Lets the check run
|
||||
/// against a fake in tests so no widget test touches the network.
|
||||
typedef GithubFetch = Future<String> Function(Uri url);
|
||||
|
||||
sealed class UpdateCheckResult {
|
||||
const UpdateCheckResult();
|
||||
}
|
||||
|
||||
/// Already on (or ahead of) the latest published release.
|
||||
class UpdateUpToDate extends UpdateCheckResult {
|
||||
const UpdateUpToDate(this.current);
|
||||
final String current;
|
||||
}
|
||||
|
||||
/// A newer release is available.
|
||||
class UpdateAvailable extends UpdateCheckResult {
|
||||
const UpdateAvailable({required this.latest, required this.url});
|
||||
final String latest;
|
||||
final String url;
|
||||
}
|
||||
|
||||
/// The check couldn't complete (offline, API error, parse failure). The app is
|
||||
/// fully functional regardless — the failure is surfaced, never silent.
|
||||
class UpdateCheckFailed extends UpdateCheckResult {
|
||||
const UpdateCheckFailed(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// clide's only outbound HTTP — a plain GET, identifying as `clide`, no body.
|
||||
Future<String> githubGet(Uri url) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(url);
|
||||
req.headers.set(HttpHeaders.userAgentHeader, 'clide');
|
||||
req.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json');
|
||||
final resp = await req.close();
|
||||
if (resp.statusCode != 200) throw HttpException('HTTP ${resp.statusCode}');
|
||||
return resp.transform(utf8.decoder).join();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `owner/repo` from a GitHub URL (`https://github.com/owner/repo[.git]`).
|
||||
({String owner, String repo})? parseGithubRepo(String repositoryUrl) {
|
||||
final m = RegExp(r'github\.com[/:]([^/]+)/([^/.\s]+)').firstMatch(repositoryUrl);
|
||||
return m == null ? null : (owner: m.group(1)!, repo: m.group(2)!);
|
||||
}
|
||||
|
||||
/// Fetch the latest GitHub Release for [repositoryUrl] and compare its version
|
||||
/// to [currentVersion]. Never throws — failures come back as [UpdateCheckFailed].
|
||||
Future<UpdateCheckResult> checkForUpdate({required String repositoryUrl, required String currentVersion, GithubFetch fetch = githubGet}) async {
|
||||
final gh = parseGithubRepo(repositoryUrl);
|
||||
if (gh == null) return const UpdateCheckFailed('unrecognized repository URL');
|
||||
try {
|
||||
final body = await fetch(Uri.parse('https://api.github.com/repos/${gh.owner}/${gh.repo}/releases/latest'));
|
||||
final json = jsonDecode(body) as Map<String, Object?>;
|
||||
final tag = (json['tag_name'] as String?)?.trim() ?? '';
|
||||
final latest = tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
if (latest.isEmpty) return const UpdateCheckFailed('no release version found');
|
||||
final url = (json['html_url'] as String?) ?? repositoryUrl;
|
||||
return compareSemver(latest, currentVersion) > 0 ? UpdateAvailable(latest: latest, url: url) : UpdateUpToDate(currentVersion);
|
||||
} catch (e) {
|
||||
return UpdateCheckFailed('$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal semver compare → -1/0/1 for a<b / a==b / a>b. Compares
|
||||
/// major.minor.patch numerically (so 2.3.10 > 2.3.9), and ranks a pre-release
|
||||
/// BELOW the same release (2.8.2-rc < 2.8.2). Missing components count as 0.
|
||||
int compareSemver(String a, String b) {
|
||||
(List<int>, String) parse(String v) {
|
||||
final dash = v.indexOf('-');
|
||||
final core = dash >= 0 ? v.substring(0, dash) : v;
|
||||
final pre = dash >= 0 ? v.substring(dash + 1) : '';
|
||||
final nums = [for (final p in core.split('.')) int.tryParse(p.trim()) ?? 0];
|
||||
while (nums.length < 3) {
|
||||
nums.add(0);
|
||||
}
|
||||
return (nums, pre);
|
||||
}
|
||||
|
||||
final (an, ap) = parse(a);
|
||||
final (bn, bp) = parse(b);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (an[i] != bn[i]) return an[i] < bn[i] ? -1 : 1;
|
||||
}
|
||||
if (ap.isEmpty && bp.isEmpty) return 0;
|
||||
if (ap.isEmpty) return 1; // release outranks a pre-release of the same core
|
||||
if (bp.isEmpty) return -1;
|
||||
return ap.compareTo(bp);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ library;
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/env/supporter_binaries.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Problem {
|
||||
@@ -69,9 +70,24 @@ class ProblemsController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
found.addAll(supporterToolProblems(activeSupporterBinaries));
|
||||
|
||||
_loading = false;
|
||||
_error = null;
|
||||
_problems = found;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Problems for STALE supporter-binary pins (T-495 / D-104): an explicit tool
|
||||
/// path that no longer points at a file — a real misconfig (a tool moved on an
|
||||
/// upgrade). A merely-unfound optional tool isn't flagged; its use-time
|
||||
/// userError covers that. Pure + testable.
|
||||
List<Problem> supporterToolProblems(SupporterBinaries? tools) {
|
||||
if (tools == null) return const [];
|
||||
return [
|
||||
for (final name in knownSupporterTools)
|
||||
if (tools.isStalePin(name))
|
||||
Problem(source: 'tools', message: 'configured path for "$name" is missing', hint: 'Re-detect, or update its path in settings (app.tools).'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,7 +18,17 @@ import 'package:clide/src/terminal/terminal.dart';
|
||||
/// job. The shared widget layer (ClidePtyView, ClidePaneChrome) keeps
|
||||
/// the two extensions visually consistent without coupling them.
|
||||
class TerminalPane extends StatefulWidget {
|
||||
const TerminalPane({super.key});
|
||||
const TerminalPane({super.key, this.argv, this.env, this.cwdOverride});
|
||||
|
||||
/// Command to run instead of the login `$SHELL` — e.g. `['claude', 'login']`
|
||||
/// for the per-repo account sign-in pane (T-485). Null spawns the shell.
|
||||
final List<String>? argv;
|
||||
|
||||
/// Extra environment for the spawned process (e.g. `CLAUDE_CONFIG_DIR`).
|
||||
final Map<String, String>? env;
|
||||
|
||||
/// Working directory override; defaults to the open workspace.
|
||||
final String? cwdOverride;
|
||||
|
||||
@override
|
||||
State<TerminalPane> createState() => _TerminalPaneState();
|
||||
@@ -79,16 +89,23 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
|
||||
// Windows has no $SHELL convention and no login-shell flag —
|
||||
// PowerShell 7 first, classic PowerShell as the always-there
|
||||
// fallback.
|
||||
// fallback. A caller-supplied argv (e.g. `claude login`, T-485) wins.
|
||||
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
|
||||
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
|
||||
final argv = widget.argv ?? (shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']);
|
||||
// The open workspace, not Directory.current — a desktop launch starts
|
||||
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
||||
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
||||
final cwd = widget.cwdOverride ?? _kernel?.project.current?.path ?? Directory.current.path;
|
||||
|
||||
final response = await ipc.request(
|
||||
'pane.spawn',
|
||||
args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
|
||||
args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.terminal.wire,
|
||||
'cwd': cwd,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
if (widget.env != null) 'env': widget.env,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/env/supporter_binaries.dart';
|
||||
|
||||
/// Settings surface for the external supporter binaries clide shells out to —
|
||||
/// claude, d2, … (D-104 / T-495). A path field per tool (`app.tools.<name>`),
|
||||
/// auto-detected on first run, plus a Re-detect action. Editing a path rebuilds
|
||||
/// the live resolver so the change takes effect without a restart.
|
||||
class ToolsSettingsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.tools-settings';
|
||||
@override
|
||||
String get title => 'Tool paths';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
ctx.settings.addListener(_sync);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
_ctx?.settings.removeListener(_sync);
|
||||
}
|
||||
|
||||
/// Rebuild the process-wide resolver from the current override keys so a path
|
||||
/// edited in the settings panel applies live. Cheap — reads a couple of keys.
|
||||
void _sync() {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return;
|
||||
activeSupporterBinaries = supporterBinariesFrom((k) => ctx.settings.get<Object>(k));
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'tools.detect',
|
||||
command: 'tools.detect',
|
||||
title: 'Re-detect tool paths',
|
||||
titleKey: 'command.detect',
|
||||
i18nNamespace: id,
|
||||
run: _redetect,
|
||||
),
|
||||
SettingsCategoryContribution(
|
||||
id: 'tools',
|
||||
category: SettingsCategory(
|
||||
id: 'tools',
|
||||
title: 'Tools',
|
||||
titleKey: 'settings.title',
|
||||
i18nNamespace: id,
|
||||
iconName: 'wrench',
|
||||
priority: 60,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Supporter binaries',
|
||||
labelKey: 'settings.section.binaries',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: supporterToolKey('claude'),
|
||||
kind: SettingsFieldKind.text,
|
||||
label: 'Claude CLI',
|
||||
labelKey: 'settings.field.claude.label',
|
||||
help: 'Absolute path to the claude binary; blank to auto-resolve.',
|
||||
helpKey: 'settings.field.claude.help',
|
||||
),
|
||||
SettingsField(
|
||||
key: supporterToolKey('d2'),
|
||||
kind: SettingsFieldKind.text,
|
||||
label: 'd2',
|
||||
labelKey: 'settings.field.d2.label',
|
||||
help: 'Absolute path to the d2 diagram compiler; blank to auto-resolve.',
|
||||
helpKey: 'settings.field.d2.help',
|
||||
),
|
||||
SettingsField(
|
||||
key: 'app.tools.redetect',
|
||||
kind: SettingsFieldKind.file,
|
||||
label: 'Re-detect',
|
||||
labelKey: 'settings.field.detect.label',
|
||||
help: 'Re-scan PATH and the common install dirs, overwriting the paths above.',
|
||||
helpKey: 'settings.field.detect.help',
|
||||
fileCommand: 'tools.detect',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _redetect(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'tools-settings not activated'),
|
||||
);
|
||||
}
|
||||
final resolver = await redetectSupporterBinaries(write: (k, v) => ctx.settings.set<Object?>(k, v));
|
||||
activeSupporterBinaries = resolver;
|
||||
return IpcResponse.ok(
|
||||
id: '',
|
||||
data: {
|
||||
'detected': {for (final t in knownSupporterTools) t: resolver.resolve(t)},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel;
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -198,10 +199,20 @@ class _StartColumn extends StatelessWidget {
|
||||
tokens: tokens,
|
||||
onTap: () => _openFolder(context),
|
||||
),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.byName('folder-plus'),
|
||||
label: ClideSettings.i18n.string(context, 'action.newProject', namespace: 'builtin.welcome', placeholder: 'New project…'),
|
||||
tokens: tokens,
|
||||
onTap: () => _newProject(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _newProject(BuildContext context) {
|
||||
kernel.dialog.show<Object>((ctx, dismiss) => _NewProjectDialog(kernel: kernel, onClose: () => dismiss()));
|
||||
}
|
||||
|
||||
void _openFolder(BuildContext context) async {
|
||||
try {
|
||||
final picked = await kernel.window.pickDirectory();
|
||||
@@ -210,7 +221,7 @@ class _StartColumn extends StatelessWidget {
|
||||
if (ok) {
|
||||
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(kernel: kernel, path: picked, onDismiss: () => dismiss()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -630,16 +641,166 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _NotARepoDialog extends StatelessWidget {
|
||||
const _NotARepoDialog({required this.path, required this.onDismiss});
|
||||
/// Shown when the opened folder isn't a git repo. Rather than a dead end, it
|
||||
/// offers to initialize the folder as a clide project (T-489): `project.init`,
|
||||
/// open, and announce on [projectCreatedChannel] so the account roadblock fires
|
||||
/// — the same path a brand-new project takes.
|
||||
class _NotARepoDialog extends StatefulWidget {
|
||||
const _NotARepoDialog({required this.kernel, required this.path, required this.onDismiss});
|
||||
final KernelServices kernel;
|
||||
final String path;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<_NotARepoDialog> createState() => _NotARepoDialogState();
|
||||
}
|
||||
|
||||
class _NotARepoDialogState extends State<_NotARepoDialog> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final r = await widget.kernel.ipc.request(
|
||||
'project.init',
|
||||
args: {
|
||||
'positional': <String>[],
|
||||
'flags': {'dir': widget.path},
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!r.ok) {
|
||||
return setState(() {
|
||||
_loading = false;
|
||||
_error = r.error?.message ?? 'Could not initialize this folder.';
|
||||
});
|
||||
}
|
||||
final opened = await widget.kernel.project.open(widget.path);
|
||||
if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': widget.path});
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.welcome', placeholder: fallback);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
width: 460,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(_t('dialog.notRepo.title', 'No git repo found'), fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(widget.path, muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_t('dialog.notRepo.body', 'A clide project needs a git repository. Initialize this folder as one?'), muted: true, fontSize: clideFontMeta),
|
||||
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: _t('button.cancel', 'Cancel'), onPressed: widget.onDismiss),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: _loading ? _t('button.initializing', 'Initializing…') : _t('dialog.notRepo.initialize', 'Initialize project'),
|
||||
onPressed: _loading ? null : _initialize,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// New-project dialog (T-488, story T-486): pick a location + name, create the
|
||||
/// project via `project.new`, open it, and announce it on [projectCreatedChannel]
|
||||
/// so the Claude extension can run the per-repo account roadblock. Stays
|
||||
/// claude-free — the account step is the consumer's job, not this dialog's.
|
||||
class _NewProjectDialog extends StatefulWidget {
|
||||
const _NewProjectDialog({required this.kernel, required this.onClose});
|
||||
final KernelServices kernel;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<_NewProjectDialog> createState() => _NewProjectDialogState();
|
||||
}
|
||||
|
||||
class _NewProjectDialogState extends State<_NewProjectDialog> {
|
||||
final TextEditingController _parent = TextEditingController();
|
||||
final TextEditingController _name = TextEditingController();
|
||||
final FocusNode _parentFocus = FocusNode(debugLabel: 'new-project-parent');
|
||||
final FocusNode _nameFocus = FocusNode(debugLabel: 'new-project-name');
|
||||
String? _error;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_parent.dispose();
|
||||
_name.dispose();
|
||||
_parentFocus.dispose();
|
||||
_nameFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _browse() async {
|
||||
try {
|
||||
final picked = await widget.kernel.window.pickDirectory();
|
||||
if (picked != null && mounted) setState(() => _parent.text = picked);
|
||||
} on MissingPluginException {
|
||||
// No native picker on this platform — the user types the path instead.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final name = _name.text.trim();
|
||||
final parent = _parent.text.trim();
|
||||
if (name.isEmpty) return setState(() => _error = 'Enter a project name.');
|
||||
if (parent.isEmpty) return setState(() => _error = 'Choose a location.');
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final r = await widget.kernel.ipc.request(
|
||||
'project.new',
|
||||
args: {
|
||||
'positional': [name],
|
||||
'flags': {'dir': parent},
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!r.ok) {
|
||||
return setState(() {
|
||||
_loading = false;
|
||||
_error = r.error?.message ?? 'Could not create the project.';
|
||||
});
|
||||
}
|
||||
final path = r.data['path'] as String;
|
||||
// Open the new workspace, then announce it — only a freshly-created project
|
||||
// announces, so only it triggers the account roadblock (T-488).
|
||||
final opened = await widget.kernel.project.open(path);
|
||||
if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': path});
|
||||
widget.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 460,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
@@ -651,30 +812,46 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.welcome', placeholder: 'No git repo found'),
|
||||
ClideSettings.i18n.string(context, 'dialog.newProject.title', namespace: 'builtin.welcome', placeholder: 'New project'),
|
||||
fontSize: clideFontDialogTitle,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(
|
||||
context,
|
||||
'dialog.notRepo.body',
|
||||
'dialog.newProject.body',
|
||||
namespace: 'builtin.welcome',
|
||||
placeholder: 'A clide project root requires a git repository.',
|
||||
placeholder: 'Creates a git repo + a CLAUDE.md, then opens it.',
|
||||
),
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(
|
||||
tokens,
|
||||
'Location',
|
||||
_parent,
|
||||
_parentFocus,
|
||||
trailing: ClideButton(label: 'Browse…', onPressed: _browse),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_field(tokens, 'Name', _name, _nameFocus, onSubmit: _create),
|
||||
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.welcome', placeholder: 'OK'),
|
||||
onPressed: () => onDismiss(),
|
||||
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.welcome', placeholder: 'Cancel'),
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: _loading
|
||||
? ClideSettings.i18n.string(context, 'button.creating', namespace: 'builtin.welcome', placeholder: 'Creating…')
|
||||
: ClideSettings.i18n.string(context, 'button.create', namespace: 'builtin.welcome', placeholder: 'Create'),
|
||||
onPressed: _loading ? null : _create,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -682,4 +859,43 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(SurfaceTokens tokens, String label, TextEditingController c, FocusNode f, {Widget? trailing, Future<void> Function()? onSubmit}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontMeta, muted: true),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: f.hasFocus ? tokens.panelActiveBorder : tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: c,
|
||||
focusNode: f,
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: clideFontCaption,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: onSubmit == null ? null : (_) => unawaited(onSubmit()),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export 'src/focus.dart';
|
||||
export 'src/i18n/catalog_loader.dart';
|
||||
export 'src/i18n/fallback_chain.dart';
|
||||
export 'src/i18n/i18n.dart';
|
||||
export 'src/i18n/tier0_namespaces.dart';
|
||||
export 'src/net.dart';
|
||||
export 'src/notify.dart';
|
||||
export 'src/os.dart';
|
||||
@@ -63,6 +64,7 @@ export 'src/panels/layout_preset.dart';
|
||||
export 'src/panels/registry.dart';
|
||||
export 'src/panels/slot_id.dart';
|
||||
export 'src/panels/view_pane_snapshot.dart';
|
||||
export 'src/theme/bundled_themes.dart';
|
||||
export 'src/theme/contrast.dart';
|
||||
export 'src/theme/controller.dart';
|
||||
export 'src/theme/loader.dart';
|
||||
|
||||
@@ -92,10 +92,15 @@ class I18n extends ChangeNotifier {
|
||||
/// Look up a key, walking the locale fallback chain. Returns the
|
||||
/// placeholder if nothing hits; returns the key itself when placeholder
|
||||
/// is null (developer fallback — keys are more useful than blanks).
|
||||
String string(String key, {required String namespace, String? placeholder}) {
|
||||
/// [warnIfMissing] false suppresses the missing-key warning for OPEN-ENDED
|
||||
/// lookups where a miss is the normal case, not a bug — e.g. tool display
|
||||
/// names (`tool.name.Bash`, `tool.name.ScheduleWakeup`, MCP tools), which are
|
||||
/// proper-name identifiers that intentionally fall back to the raw name. Keep
|
||||
/// the warning on for fixed UI strings, where a miss is a real translation gap.
|
||||
String string(String key, {required String namespace, String? placeholder, bool warnIfMissing = true}) {
|
||||
final byLocale = _cache[namespace];
|
||||
if (byLocale == null) {
|
||||
_warnOnce('$namespace::MISSING_NAMESPACE::$key', 'i18n: namespace not registered: $namespace (key: $key)');
|
||||
if (warnIfMissing) _warnOnce('$namespace::MISSING_NAMESPACE::$key', 'i18n: namespace not registered: $namespace (key: $key)');
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
@@ -108,7 +113,9 @@ class I18n extends ChangeNotifier {
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
|
||||
_warnOnce('$namespace::${_current.languageCode}::$key', 'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})');
|
||||
if (warnIfMissing) {
|
||||
_warnOnce('$namespace::${_current.languageCode}::$key', 'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})');
|
||||
}
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Canonical list of Tier-0 i18n namespaces preloaded at boot — the single
|
||||
/// source of truth (T-371).
|
||||
///
|
||||
/// `lib/main.dart` preloads exactly these (framework chrome under `core`, plus
|
||||
/// the catalogs of the built-ins that activate at Tier 0); the i18n coverage
|
||||
/// gate validates every one of them. The other ~17 catalogs that ship belong
|
||||
/// to built-ins that activate lazily in later tiers — their catalogs load on
|
||||
/// activation, and the gate validates them through the assets-dir sweep rather
|
||||
/// than this preload list.
|
||||
library;
|
||||
|
||||
/// Namespaces whose catalogs are loaded at boot, before any extension that
|
||||
/// owns them has activated. `core` holds framework chrome that lives outside
|
||||
/// any extension (lib/widgets, lib/kernel, shared reader chrome — T-469).
|
||||
const List<String> kTier0Namespaces = [
|
||||
'core',
|
||||
'builtin.default-layout',
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.terminal',
|
||||
'builtin.files',
|
||||
'builtin.claude',
|
||||
'builtin.editor',
|
||||
];
|
||||
@@ -15,11 +15,16 @@ class OsLifecycleEvent extends ClideEvent {
|
||||
String get kind => _kind;
|
||||
}
|
||||
|
||||
/// Runs an external command — injected so [OsBridge] is testable without
|
||||
/// spawning a real `xdg-open` / `open` / `explorer`.
|
||||
typedef OsProcessRunner = Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||
|
||||
class OsBridge {
|
||||
OsBridge({required Logger log, required DaemonBus events}) : _log = log, _events = events;
|
||||
OsBridge({required Logger log, required DaemonBus events, OsProcessRunner? run}) : _log = log, _events = events, _run = run ?? Process.run;
|
||||
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
final OsProcessRunner _run;
|
||||
|
||||
Future<bool> openURL(String url) async {
|
||||
final cmd = _openCommand();
|
||||
@@ -28,7 +33,7 @@ class OsBridge {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final r = await Process.run(cmd[0], [...cmd.skip(1), url]);
|
||||
final r = await _run(cmd[0], [...cmd.skip(1), url]);
|
||||
return r.exitCode == 0;
|
||||
} catch (e) {
|
||||
_log.warn('os', 'openURL failed', error: e);
|
||||
@@ -40,7 +45,7 @@ class OsBridge {
|
||||
final cmd = _revealCommand(path);
|
||||
if (cmd == null) return false;
|
||||
try {
|
||||
final r = await Process.run(cmd[0], cmd.skip(1).toList());
|
||||
final r = await _run(cmd[0], cmd.skip(1).toList());
|
||||
return r.exitCode == 0;
|
||||
} catch (e) {
|
||||
_log.warn('os', 'reveal failed', error: e);
|
||||
|
||||
@@ -113,6 +113,15 @@ class SettingsStore extends ChangeNotifier {
|
||||
SettingsScope.ext => null,
|
||||
};
|
||||
|
||||
/// Every key currently stored in [layer] (no cross-layer merge) — for
|
||||
/// prefix-scan consumers like the per-workspace account-binding enumerator
|
||||
/// (T-480). [SettingsScope.ext] is a key class, not a layer → empty.
|
||||
Iterable<String> keysAt(SettingsScope layer) => switch (layer) {
|
||||
SettingsScope.app => _appValues.keys,
|
||||
SettingsScope.project => _projectValues.keys,
|
||||
SettingsScope.ext => const <String>[],
|
||||
};
|
||||
|
||||
/// The storage layer currently supplying [key]'s value (project overrides app
|
||||
/// for `ext.*`), or null when unset (Default). Honors the key's prefix.
|
||||
SettingsScope? effectiveLayer(String key) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Canonical list of the bundled theme asset paths — the single source of
|
||||
/// truth for which themes ship (T-371).
|
||||
///
|
||||
/// Three consumers used to hand-maintain their own copy and drifted apart
|
||||
/// (main.dart loaded 10, the testmode harness loaded 8 — catppuccin was
|
||||
/// silently unvalidated, and the WCAG contrast gate had a third copy):
|
||||
///
|
||||
/// - `lib/main.dart` `_loadBundledThemes()` — what the running app loads.
|
||||
/// - `lib/test_app.dart` — the testmode platform-integration harness.
|
||||
/// - `test/a11y/contrast_test.dart` — the WCAG-AA contrast gate.
|
||||
///
|
||||
/// They now all iterate this list, so adding a theme here loads it, validates
|
||||
/// it, and ships it everywhere at once. The contrast gate additionally asserts
|
||||
/// every `.yaml` under [kThemesDir] is in this list, so a new theme cannot sit
|
||||
/// on disk (or ship) unvalidated.
|
||||
library;
|
||||
|
||||
/// Directory holding the theme YAML sources, relative to the repo root.
|
||||
const String kThemesDir = 'lib/kernel/src/theme/themes';
|
||||
|
||||
/// Theme YAML asset paths bundled into the app, in display order: the base
|
||||
/// themes, then their `-hc` high-contrast siblings, then third-party / ported
|
||||
/// palettes paired with their own `-hc` siblings. Every base theme has a
|
||||
/// structurally identical `-hc` sibling that clears the strict contrast gate.
|
||||
const List<String> kBundledThemePaths = [
|
||||
'$kThemesDir/clide.yaml',
|
||||
'$kThemesDir/midnight.yaml',
|
||||
'$kThemesDir/paper.yaml',
|
||||
'$kThemesDir/terminal.yaml',
|
||||
'$kThemesDir/clide-hc.yaml',
|
||||
'$kThemesDir/midnight-hc.yaml',
|
||||
'$kThemesDir/paper-hc.yaml',
|
||||
'$kThemesDir/terminal-hc.yaml',
|
||||
'$kThemesDir/catppuccin-mocha.yaml',
|
||||
'$kThemesDir/catppuccin-mocha-hc.yaml',
|
||||
'$kThemesDir/summer-night.yaml',
|
||||
'$kThemesDir/summer-night-hc.yaml',
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
# summer-night-hc — high-contrast sibling of `summer-night`.
|
||||
#
|
||||
# Same cyan/teal/pink silhouette and identical schema; muted text, status
|
||||
# chips, syntax tokens, and the focus border are brightened to clear the
|
||||
# strict contrast gate (D-22 + D-69, `extendedPairs` in
|
||||
# lib/kernel/src/theme/contrast.dart). Backgrounds/surface/foreground are
|
||||
# unchanged from the base so the two read as the same theme.
|
||||
|
||||
name: summer-night-hc
|
||||
display_name: Summer Night (high contrast)
|
||||
dark: true
|
||||
|
||||
palette:
|
||||
primary: "#5cc8ec"
|
||||
secondary: "#5fd6cb"
|
||||
accent: "#ff84a6"
|
||||
|
||||
background: "#21262f"
|
||||
surface: "#393e48"
|
||||
panel: "#292e38"
|
||||
|
||||
foreground: "#e2e8f5"
|
||||
muted: "#c4cad6"
|
||||
|
||||
success: "#3fd6c0"
|
||||
warning: "#e6b072"
|
||||
error: "#ff9b99"
|
||||
info: "#6cc8ec"
|
||||
|
||||
surfaceHi: "#434a57"
|
||||
border: "#3a414d"
|
||||
borderHi: "#5cc8ec"
|
||||
textDim: "#c4cad6"
|
||||
textMute: "#9aa2b2"
|
||||
accentSoft: "#21ff84a6"
|
||||
|
||||
syntax:
|
||||
keyword: "#ffa0c0" # pink
|
||||
type: "#6cd4f0" # cyan
|
||||
string: "#9fe0c0" # green
|
||||
number: "#f0c888" # amber
|
||||
comment: "#9aa2b2" # muted
|
||||
method: "#80d0e8" # teal-cyan
|
||||
punct: "#c4cad6" # dim
|
||||
@@ -1,24 +1,27 @@
|
||||
# Summer Night — ported from legacy clide v1.2.0.
|
||||
# Palette-only; the three-tier resolver fills semantic + surface from
|
||||
# defaults. Override sections land here as the token surface grows.
|
||||
# Summer Night — cyan/teal/pink on blue-grey (ported from legacy clide v1.2.0).
|
||||
#
|
||||
# The 12 original palette colors are kept as the v1.2 designer chose them. The
|
||||
# expanded keys (surfaceHi, border/borderHi, textDim/textMute, accentSoft) and
|
||||
# the syntax set follow the clide derivation pattern, re-tinted to this theme's
|
||||
# identity. Base honours the design and clears the canonical contrast gate;
|
||||
# the high-contrast sibling (summer-night-hc) clears the strict extended gate.
|
||||
|
||||
name: summer-night
|
||||
display_name: Summer Night
|
||||
dark: true
|
||||
|
||||
palette:
|
||||
# Accents (legacy names: primary/secondary/accent)
|
||||
primary: "#00a3d2" # cyan
|
||||
secondary: "#00a9b9" # teal
|
||||
accent: "#fa5f8b" # pink
|
||||
# Accents (legacy: primary cyan / secondary teal / accent pink)
|
||||
primary: "#00a3d2"
|
||||
secondary: "#00a9b9"
|
||||
accent: "#fa5f8b"
|
||||
|
||||
# Backgrounds
|
||||
background: "#21262f"
|
||||
surface: "#393e48"
|
||||
panel: "#292e38"
|
||||
|
||||
# Text. `muted` is WCAG-AA-calibrated against `panel` — don't darken
|
||||
# without re-running the a11y/contrast suite.
|
||||
# Text. `muted` is WCAG-AA-calibrated against `panel`.
|
||||
foreground: "#e2e8f5"
|
||||
muted: "#a6adbb"
|
||||
|
||||
@@ -27,3 +30,20 @@ palette:
|
||||
warning: "#d08447"
|
||||
error: "#f06c6f"
|
||||
info: "#00a3d2"
|
||||
|
||||
# Expanded keys (clide pattern, summer-night tint)
|
||||
surfaceHi: "#434a57"
|
||||
border: "#3a414d"
|
||||
borderHi: "#4a5260"
|
||||
textDim: "#9aa2b2"
|
||||
textMute: "#727a8a"
|
||||
accentSoft: "#21fa5f8b"
|
||||
|
||||
syntax:
|
||||
keyword: "#fa8fb0" # pink
|
||||
type: "#5ec8e6" # cyan
|
||||
string: "#7fd3a8" # green
|
||||
number: "#e0b070" # amber
|
||||
comment: "#727a8a" # muted
|
||||
method: "#6fc6e0" # teal-cyan
|
||||
punct: "#9aa2b2" # dim
|
||||
|
||||
@@ -27,19 +27,30 @@ import 'package:clide/builtin/problems/problems.dart';
|
||||
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
||||
import 'package:clide/builtin/terminal/terminal.dart';
|
||||
import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/builtin/tools_settings/tools_settings.dart';
|
||||
import 'package:clide/builtin/view/view.dart';
|
||||
import 'package:clide/builtin/vim/vim.dart';
|
||||
import 'package:clide/builtin/tickets/tickets.dart';
|
||||
import 'package:clide/builtin/todos/todos.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:io' show Directory, File, Platform, pid;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/clide.dart' show clideVersion;
|
||||
import 'package:clide/src/daemon/claude_account_commands.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/draw_commands.dart';
|
||||
import 'package:clide/src/draw/compare_template.dart' show compareTemplateHandler;
|
||||
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
|
||||
import 'package:clide/src/draw/graph_template.dart' show graphTemplateHandler;
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/icon_commands.dart';
|
||||
import 'package:clide/src/daemon/image_commands.dart';
|
||||
import 'package:clide/src/daemon/project_commands.dart';
|
||||
import 'package:clide/src/daemon/instance_command.dart';
|
||||
import 'package:clide/src/daemon/log_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/status_command.dart';
|
||||
@@ -52,6 +63,8 @@ import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
|
||||
import 'package:clide/src/env/supporter_binaries.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs;
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/mcp_server.dart';
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
|
||||
@@ -108,6 +121,10 @@ Future<void> main() async {
|
||||
await primeLoginShellPath();
|
||||
final bootSettings = SettingsStore(appDir: appDir);
|
||||
await bootSettings.load();
|
||||
// Resolve external supporter binaries (claude, d2, …) — explicit user-scope
|
||||
// overrides first, else the primed PATH + the well-known dirs; auto-detect
|
||||
// and pin them on first run (T-495, D-104).
|
||||
activeSupporterBinaries = await loadSupporterBinaries(read: (k) => bootSettings.get<Object>(k), write: (k, v) => bootSettings.set<Object?>(k, v));
|
||||
startupWorkRoot = resolveStartupWorkspace(
|
||||
cwdRoot: startupWorkRoot,
|
||||
lastProject: bootSettings.get<String>('app.lastProject'),
|
||||
@@ -227,7 +244,19 @@ Future<void> main() async {
|
||||
ipcLog.error('ipc', 'server start failed', error: e, stackTrace: st);
|
||||
return;
|
||||
}
|
||||
final mcp = McpServer(workspaceRoot: workRoot.path, log: ipcLog, dispatcher: dispatcher);
|
||||
final mcp = McpServer(
|
||||
workspaceRoot: workRoot.path,
|
||||
log: ipcLog,
|
||||
dispatcher: dispatcher,
|
||||
// T-479: when this workspace is bound to a Claude account, also write the
|
||||
// /ide discovery lock into that account's config dir so a `claude` started
|
||||
// with CLAUDE_CONFIG_DIR=<dir> can reach clide's bridge. Lazy — resolved
|
||||
// post-boot once kernelSettings (and the registry) exist.
|
||||
boundConfigDir: () {
|
||||
final s = kernelSettings;
|
||||
return s == null ? null : AccountRegistry(s).accountForWorkspace(workRoot.path)?.dir;
|
||||
},
|
||||
);
|
||||
mcpServer = mcp;
|
||||
try {
|
||||
await mcp.start();
|
||||
@@ -286,6 +315,10 @@ Future<void> main() async {
|
||||
// visible to `pane list` by snapshotting the kernel PanelRegistry +
|
||||
// LayoutArrangement at request time — no mirrored state to drift.
|
||||
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
|
||||
// `clide instance` — this instance's identity (version/pid/workspace/socket)
|
||||
// so `clide instances` can list every live instance and a human/agent can
|
||||
// tell which one a socket belongs to (T-247).
|
||||
registerInstanceCommand(dispatcher, version: clideVersion, pid: pid, workspace: workRoot.path, socketPath: workspaceSocketPath(workRoot.path));
|
||||
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
|
||||
// D-6 parity with the output-dock Level chip). Persists via the kernel
|
||||
// settings, captured post-boot.
|
||||
@@ -312,6 +345,15 @@ Future<void> main() async {
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
final gitClient = GitClient(toolchain: tc, workDir: workRoot);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
// `clide project new <name>` (T-487): create + git-init a new project dir.
|
||||
// git init runs over the *new* dir via the toolchain; --dir defaults to the
|
||||
// current workspace's parent so a new project lands beside this one.
|
||||
registerProjectCommands(
|
||||
dispatcher,
|
||||
gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(),
|
||||
defaultParent: () => workRoot.parent.path,
|
||||
defaultInitPath: () => workRoot.path,
|
||||
);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: tc);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
|
||||
@@ -330,6 +372,73 @@ Future<void> main() async {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
return file.existsSync() ? file.absolute.path : null;
|
||||
},
|
||||
readFile: (path) async {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
try {
|
||||
return file.existsSync() ? await file.readAsString() : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
// `clide icon show <name…>` — drive a Phosphor glyph card into the Claude
|
||||
// conversation (T-313). Resolves names via the bundled glyph table.
|
||||
registerIconCommands(
|
||||
dispatcher,
|
||||
() => kernelMessages?.publish,
|
||||
resolve: (name) => kPhosphorGlyphs[name],
|
||||
readFile: (path) async {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
try {
|
||||
return file.existsSync() ? await file.readAsString() : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
// `clide draw --file <doc>` — drive a drawing card into the Claude
|
||||
// conversation (T-318, drive-half of D-6). Reads the JSON doc relative to
|
||||
// workRoot, lowers it to SVG via the template registry (primitive svg now;
|
||||
// d2/icon/compare/image handlers register as they land), then publishes a
|
||||
// 'draw' message the Claude extension injects.
|
||||
registerDrawCommands(
|
||||
dispatcher,
|
||||
() => kernelMessages?.publish,
|
||||
registry: DrawingRegistry()
|
||||
..register('d2', d2TemplateHandler())
|
||||
..register('graph', graphTemplateHandler())
|
||||
..register(
|
||||
'compare',
|
||||
compareTemplateHandler(
|
||||
resolvePath: (path) {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
return file.existsSync() ? file.absolute.path : null;
|
||||
},
|
||||
),
|
||||
),
|
||||
readFile: (path) async {
|
||||
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
|
||||
try {
|
||||
return file.existsSync() ? await file.readAsString() : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
// `clide claude account …` — manage per-repo Claude accounts (T-480, epic
|
||||
// T-476). Registry reads/writes go through the user-scope SettingsStore;
|
||||
// side-effects (respawn, login pane, --purge) are published on
|
||||
// accountActionChannel for the Claude extension to perform.
|
||||
registerClaudeAccountCommands(
|
||||
dispatcher,
|
||||
() {
|
||||
final settings = kernelSettings;
|
||||
final home = Platform.environment['HOME'];
|
||||
if (settings == null || home == null || home.isEmpty) return null;
|
||||
return _AccountStoreAdapter(AccountRegistry(settings), home);
|
||||
},
|
||||
publisher: () => kernelMessages?.publish,
|
||||
workspaceCwd: () => workRoot.path,
|
||||
);
|
||||
// `clide status` — one-shot orientation snapshot (T-221): active pane,
|
||||
// focused file + selection, git summary, layout. Assembled here where the
|
||||
@@ -393,7 +502,7 @@ Future<void> main() async {
|
||||
appDir: appDir,
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: _tier0Namespaces,
|
||||
preloadNamespaces: kTier0Namespaces,
|
||||
// Languages the UI can switch to (Settings → Appearance, T-462). Each needs
|
||||
// an assets/i18n/<locale>/ catalog folder; root_shell applies the persisted
|
||||
// app.locale on boot.
|
||||
@@ -446,6 +555,11 @@ Future<void> main() async {
|
||||
kernelMessages = services.messages;
|
||||
kernelFilterStates = services.filterStates;
|
||||
kernelSettings = services.settings;
|
||||
// T-479: the account registry is now resolvable (kernelSettings is set), so
|
||||
// re-sync the /ide discovery locks to pick up any account bound to this
|
||||
// workspace at boot, and re-sync on every account binding change.
|
||||
unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value());
|
||||
services.messages.subscribe(channel: accountActionChannel).listen((_) => unawaited(mcpServer?.syncDiscoveryLocks() ?? Future<void>.value()));
|
||||
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
|
||||
// shows socket-side logs alongside kernel/extension ones.
|
||||
ipcLog.addSink(services.logRing.add);
|
||||
@@ -488,6 +602,7 @@ Future<void> main() async {
|
||||
..register(ExtensionsUiExtension())
|
||||
..register(KeybindingsUiExtension())
|
||||
..register(ClaudeControlExtension())
|
||||
..register(ToolsSettingsExtension())
|
||||
..register(CliInstallExtension());
|
||||
|
||||
await services.extensions.activateAll();
|
||||
@@ -506,6 +621,40 @@ Future<void> main() async {
|
||||
runApp(ClideApp(services: services));
|
||||
}
|
||||
|
||||
/// Adapts the foundation-bound [AccountRegistry] + bootstrap probe to the
|
||||
/// Flutter-free [AccountStore] port the `claude account` verbs use (T-480).
|
||||
class _AccountStoreAdapter implements AccountStore {
|
||||
_AccountStoreAdapter(this._reg, this._home);
|
||||
final AccountRegistry _reg;
|
||||
final String _home;
|
||||
|
||||
@override
|
||||
List<({String name, String dir})> get accounts => [for (final a in _reg.accounts) (name: a.name, dir: a.dir)];
|
||||
@override
|
||||
String? boundAccountName(String cwd) => _reg.boundName(cwd);
|
||||
@override
|
||||
Set<String> boundAccountNames() => _reg.boundAccountNames();
|
||||
@override
|
||||
String defaultDirFor(String name) => '$_home/.claude-$name';
|
||||
@override
|
||||
List<String> detectedDirs() {
|
||||
final registered = {for (final a in _reg.accounts) a.dir};
|
||||
return [
|
||||
for (final d in probeExistingAccountDirs(_home))
|
||||
if (!registered.contains(d.dir)) d.dir,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> add(String name, String dir) => _reg.registerAccount(name, dir);
|
||||
@override
|
||||
Future<void> remove(String name) => _reg.removeAccount(name);
|
||||
@override
|
||||
Future<void> bind(String cwd, String name) => _reg.bindWorkspace(cwd, name);
|
||||
@override
|
||||
Future<void> unbind(String cwd) => _reg.unbindWorkspace(cwd);
|
||||
}
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
@@ -537,39 +686,13 @@ Future<Directory> _resolveAppDir() async {
|
||||
|
||||
Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
||||
const loader = ThemeLoader();
|
||||
const paths = [
|
||||
'lib/kernel/src/theme/themes/clide.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight.yaml',
|
||||
'lib/kernel/src/theme/themes/paper.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal.yaml',
|
||||
'lib/kernel/src/theme/themes/clide-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/paper-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/catppuccin-mocha.yaml',
|
||||
'lib/kernel/src/theme/themes/catppuccin-mocha-hc.yaml',
|
||||
];
|
||||
final out = <ThemeDefinition>[];
|
||||
for (final p in paths) {
|
||||
for (final p in kBundledThemePaths) {
|
||||
out.add(await loader.fromAsset(rootBundle, p));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Every Tier-0 extension that ships an i18n catalog. Extensions
|
||||
/// registered but not active (the 17 stubs) don't preload — their
|
||||
/// catalogs load lazily on activate in later tiers.
|
||||
|
||||
const List<String> _tier0Namespaces = [
|
||||
// Framework chrome that lives outside any extension (lib/widgets, lib/kernel,
|
||||
// shared reader chrome) resolves under the 'core' namespace (T-469).
|
||||
'core',
|
||||
'builtin.default-layout',
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.terminal',
|
||||
'builtin.files',
|
||||
'builtin.claude',
|
||||
'builtin.editor',
|
||||
];
|
||||
// The Tier-0 i18n namespace list now lives in
|
||||
// lib/kernel/src/i18n/tier0_namespaces.dart as `kTier0Namespaces`, the single
|
||||
// source of truth shared with the i18n coverage gate (T-371).
|
||||
|
||||
@@ -36,7 +36,21 @@ ArgvParseResult unwrapArgvRequest(IpcRequest outer) {
|
||||
),
|
||||
);
|
||||
}
|
||||
return parseArgv(raw.cast<String>(), requestId: outer.id);
|
||||
final result = parseArgv(raw.cast<String>(), requestId: outer.id);
|
||||
// A piped `--stdin` payload (T-315): the C client slurps stdin and ships it
|
||||
// alongside the argv. Fold it into the inner request as a `stdin` flag so it
|
||||
// surfaces as a named arg (undeclared keys pass the schema untouched) — the
|
||||
// handler reads it as the structured payload, the piped peer of `--file`.
|
||||
final stdin = outer.args['stdin'];
|
||||
if (result is ArgvParsed && stdin is String) {
|
||||
final req = result.request;
|
||||
final args = Map<String, Object?>.from(req.args);
|
||||
final flags = Map<String, Object?>.from((args['flags'] as Map?) ?? const {});
|
||||
flags['stdin'] = stdin;
|
||||
args['flags'] = flags;
|
||||
return ArgvParsed(IpcRequest(id: req.id, cmd: req.cmd, args: args));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Wire the `_argv` sentinel handler onto [dispatcher]. The handler
|
||||
|
||||
@@ -27,7 +27,7 @@ import 'package:clide/src/ipc/schema_v1.dart';
|
||||
/// split. Match the IDs the dispatcher exposes directly. `tail` and
|
||||
/// `events` are handled by the IPC server itself (streaming / cursor-pull
|
||||
/// event reads, T-129 / T-223) rather than the dispatcher.
|
||||
const Set<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities'};
|
||||
const Set<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities', 'instance', 'draw'};
|
||||
|
||||
/// Sealed result of translating argv. Caller (the IPC server, or the
|
||||
/// C client wrapper in T-126) handles either branch.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/// Registers the `claude account …` verbs — the CLI half of the per-repo
|
||||
/// Claude account feature (T-480, epic T-476; D-6 CLI parity). UI tickets
|
||||
/// (T-481/T-482) call these verbs only; nothing else writes the registry.
|
||||
///
|
||||
/// `clide claude account add <name> [--dir <path>]`
|
||||
/// `clide claude account list`
|
||||
/// `clide claude account login <name>`
|
||||
/// `clide claude account set <name>`
|
||||
/// `clide claude account unset`
|
||||
/// `clide claude account remove <name> [--purge]`
|
||||
///
|
||||
/// The argv grammar splits the first two tokens as `subsystem.verb`, so the
|
||||
/// command id is `claude.account` and the sub-verb arrives as the first
|
||||
/// positional. Registry reads/writes go through an injected [AccountStore] port
|
||||
/// and side-effects (respawn on set/unset, the login terminal pane, --purge rm)
|
||||
/// are published on [accountActionChannel] for the Claude extension to perform —
|
||||
/// keeping this handler Flutter-free so it runs under `dart test`.
|
||||
library;
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
import 'ui_command.dart' show MessagePublisher;
|
||||
|
||||
/// The MessageBus channel account actions publish on; the Claude extension
|
||||
/// subscribes to the same literal to perform the side-effects.
|
||||
const accountActionChannel = 'claude.account';
|
||||
|
||||
/// Flutter-free port over the (foundation-bound) AccountRegistry, injected so
|
||||
/// this command runs under `dart test`. main.dart adapts the real registry +
|
||||
/// bootstrap probe to it.
|
||||
abstract class AccountStore {
|
||||
/// Registered accounts as `(name, dir)` records, in stored order.
|
||||
List<({String name, String dir})> get accounts;
|
||||
|
||||
/// The account name bound to [cwd], or null.
|
||||
String? boundAccountName(String cwd);
|
||||
|
||||
/// Every account name some workspace is bound to (for the in-use check).
|
||||
Set<String> boundAccountNames();
|
||||
|
||||
/// Default config dir for a new account [name] (e.g. `~/.claude-<name>`).
|
||||
String defaultDirFor(String name);
|
||||
|
||||
/// Unregistered `~/.claude-*` dirs the bootstrap probe found (adoption hints).
|
||||
List<String> detectedDirs();
|
||||
|
||||
Future<void> add(String name, String dir);
|
||||
Future<void> remove(String name);
|
||||
Future<void> bind(String cwd, String name);
|
||||
Future<void> unbind(String cwd);
|
||||
}
|
||||
|
||||
/// Register `claude.account`. [store] / [publisher] / [workspaceCwd] are
|
||||
/// late-bound closures (captured post-boot in main.dart); each may be null in
|
||||
/// a headless context, in which case the verb degrades to a clear error.
|
||||
void registerClaudeAccountCommands(
|
||||
DaemonDispatcher d,
|
||||
AccountStore? Function() store, {
|
||||
MessagePublisher? Function()? publisher,
|
||||
String? Function()? workspaceCwd,
|
||||
}) {
|
||||
d.register(
|
||||
'claude.account',
|
||||
(req) async => _dispatch(req, store(), publisher?.call(), workspaceCwd?.call()),
|
||||
schema: const CommandSchema(
|
||||
positional: ['action', 'name'],
|
||||
args: {
|
||||
'action': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
'name': ArgSpec(rejectLeadingDash: true),
|
||||
'dir': ArgSpec(),
|
||||
'purge': ArgSpec(type: ArgType.boolean),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({String name, String dir})? _byName(AccountStore store, String name) {
|
||||
for (final a in store.accounts) {
|
||||
if (a.name == name) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<IpcResponse> _dispatch(IpcRequest req, AccountStore? store, MessagePublisher? publish, String? cwd) async {
|
||||
if (store == null) return _err(req.id, 'account registry unavailable in this context');
|
||||
final action = (req.args['action'] as String?)?.trim();
|
||||
final name = (req.args['name'] as String?)?.trim();
|
||||
final dir = (req.args['dir'] as String?)?.trim();
|
||||
final purge = req.args['purge'] == true;
|
||||
|
||||
switch (action) {
|
||||
case 'list':
|
||||
return _ok(req.id, {
|
||||
'accounts': [
|
||||
for (final a in store.accounts) {'name': a.name, 'dir': a.dir},
|
||||
],
|
||||
'boundAccount': cwd == null ? null : store.boundAccountName(cwd),
|
||||
'detected': store.detectedDirs(),
|
||||
});
|
||||
|
||||
case 'add':
|
||||
if (name == null || name.isEmpty) return _err(req.id, 'account add requires a <name>');
|
||||
final target = (dir == null || dir.isEmpty) ? store.defaultDirFor(name) : dir;
|
||||
final existing = _byName(store, name);
|
||||
if (existing != null) {
|
||||
// Idempotent: same dir is a no-op; a conflicting --dir is a userError.
|
||||
if (existing.dir == target) return _ok(req.id, {'name': name, 'dir': target, 'created': false});
|
||||
return _err(req.id, 'account "$name" already exists at ${existing.dir}', hint: 'remove it first, or omit --dir to keep it');
|
||||
}
|
||||
await store.add(name, target);
|
||||
return _ok(req.id, {'name': name, 'dir': target, 'created': true});
|
||||
|
||||
case 'remove':
|
||||
if (name == null || name.isEmpty) return _err(req.id, 'account remove requires a <name>');
|
||||
final removing = _byName(store, name);
|
||||
if (removing == null) return _err(req.id, 'no such account: "$name"');
|
||||
if (store.boundAccountNames().contains(name)) {
|
||||
return _err(req.id, 'account "$name" is bound to a workspace', hint: 'clide claude account unset (in that workspace) first');
|
||||
}
|
||||
await store.remove(name);
|
||||
// The dir delete is IO the extension owns (this handler is Flutter-free).
|
||||
// Carry the dir in the payload — the account is gone from the registry now.
|
||||
if (purge) publish?.call('cli', accountActionChannel, {'action': 'purge', 'name': name, 'dir': removing.dir});
|
||||
return _ok(req.id, {'removed': name, 'purge': purge});
|
||||
|
||||
case 'set':
|
||||
if (name == null || name.isEmpty) return _err(req.id, 'account set requires a <name>');
|
||||
if (cwd == null) return _err(req.id, 'no workspace to bind');
|
||||
if (_byName(store, name) == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name');
|
||||
await store.bind(cwd, name);
|
||||
// The extension respawns the active pane(s) on the new account.
|
||||
publish?.call('cli', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
|
||||
return _ok(req.id, {'bound': name, 'cwd': cwd});
|
||||
|
||||
case 'unset':
|
||||
if (cwd == null) return _err(req.id, 'no workspace to unbind');
|
||||
final prev = store.boundAccountName(cwd);
|
||||
await store.unbind(cwd);
|
||||
publish?.call('cli', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': prev});
|
||||
return _ok(req.id, {'unbound': prev, 'cwd': cwd});
|
||||
|
||||
case 'login':
|
||||
if (name == null || name.isEmpty) return _err(req.id, 'account login requires a <name>');
|
||||
final acct = _byName(store, name);
|
||||
if (acct == null) return _err(req.id, 'no such account: "$name"', hint: 'clide claude account add $name');
|
||||
// The extension spawns `CLAUDE_CONFIG_DIR=<dir> claude login` in a pane.
|
||||
publish?.call('cli', accountActionChannel, {'action': 'login', 'name': name, 'dir': acct.dir});
|
||||
return _ok(req.id, {'login': name, 'dir': acct.dir});
|
||||
|
||||
default:
|
||||
return _err(
|
||||
req.id,
|
||||
'unknown account action: ${action == null || action.isEmpty ? '(none)' : action}',
|
||||
hint: 'use: add | list | login | set | unset | remove',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IpcResponse _ok(String id, Map<String, Object?> data) => IpcResponse.ok(id: id, data: data);
|
||||
|
||||
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
/// Registers `draw` — drive a drawing card into the Claude conversation from
|
||||
/// the CLI (T-318, D-91 / D-103, D-6 parity).
|
||||
///
|
||||
/// clide draw --file card.json
|
||||
///
|
||||
/// The card is clide-owned rendering (the SVG engine + the [DrawingCard]
|
||||
/// widget); this is its CLI counterpart. Mirroring `image.show`, the handler is
|
||||
/// decoupled from the live UI: it reads + parses the JSON document (via an
|
||||
/// injected [DrawingFileReader]), lowers it to an SVG string through the
|
||||
/// template [DrawingRegistry], then publishes a `draw` message on the kernel
|
||||
/// MessageBus. A consumer in the Claude extension builds the SvgDocument and
|
||||
/// injects the card into the primary session. Flutter-free so it runs under
|
||||
/// `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import '../draw/draw_dispatch.dart';
|
||||
import '../draw/draw_doc.dart';
|
||||
|
||||
export '../draw/draw_dispatch.dart' show DrawErr, DrawOk, DrawResult, DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
import 'ui_command.dart' show MessagePublisher;
|
||||
|
||||
/// The MessageBus channel `draw` publishes on; the Claude extension subscribes
|
||||
/// to the same literal to inject the card.
|
||||
const drawShowChannel = 'draw';
|
||||
|
||||
void registerDrawCommands(
|
||||
DaemonDispatcher d,
|
||||
MessagePublisher? Function() publisher, {
|
||||
required DrawingRegistry registry,
|
||||
required DrawingFileReader readFile,
|
||||
}) {
|
||||
d.register(
|
||||
'draw',
|
||||
(req) async => _draw(req, publisher, registry, readFile),
|
||||
schema: const CommandSchema(args: {'file': ArgSpec(required: true, rejectLeadingDash: true)}),
|
||||
);
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _draw(IpcRequest req, MessagePublisher? Function() publisherSource, DrawingRegistry registry, DrawingFileReader readFile) async {
|
||||
final file = req.args['file'] as String?;
|
||||
if (file == null || file.trim().isEmpty) {
|
||||
return _userErr(req.id, 'a drawing-card document is required (e.g. `draw --file card.json`)');
|
||||
}
|
||||
|
||||
final raw = await readFile(file);
|
||||
if (raw == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such file: $file',
|
||||
hint: 'path is resolved relative to the workspace root',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Type inference from the extension (T-494): a `.d2`/`.svg` file is the raw
|
||||
// source, not a JSON envelope — wrap it in the matching doc. Everything else
|
||||
// is a drawing-card JSON document.
|
||||
final lower = file.toLowerCase();
|
||||
final DrawingCardDoc? doc;
|
||||
if (lower.endsWith('.d2')) {
|
||||
doc = DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': raw});
|
||||
} else if (lower.endsWith('.svg')) {
|
||||
doc = DrawingCardDoc(svg: raw);
|
||||
} else {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(raw);
|
||||
} on FormatException catch (e) {
|
||||
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
|
||||
}
|
||||
doc = parseDrawingCardDoc(decoded);
|
||||
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
|
||||
}
|
||||
|
||||
final result = await resolveDrawingSvg(doc, registry, readFile: readFile);
|
||||
if (result is DrawErr) return _userErr(req.id, result.message);
|
||||
final svg = (result as DrawOk).svg;
|
||||
|
||||
final publish = publisherSource();
|
||||
if (publish == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
|
||||
publish('cli', drawShowChannel, {
|
||||
'svg': svg,
|
||||
if (doc.label != null) 'label': doc.label,
|
||||
if (doc.description != null) 'description': doc.description,
|
||||
// d2 cards carry their source so the card can offer a "view source" peek.
|
||||
if (doc.template == 'd2' && doc.fields['source'] is String) 'source': doc.fields['source'],
|
||||
});
|
||||
return IpcResponse.ok(id: req.id, data: {'shown': true, if (doc.template != null) 'template': doc.template});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// Registers `icon.show` — drive a Phosphor glyph card into the Claude
|
||||
/// conversation from the CLI (T-313, D-6 parity).
|
||||
///
|
||||
/// clide icon show gear folder gauge
|
||||
/// clide icon show --file icons.json # entries with label/description/color
|
||||
///
|
||||
/// Mirrors `image.show` end to end: a Flutter-free handler validates + resolves
|
||||
/// each glyph (via an injected [IconResolver], so dart-test needs no font),
|
||||
/// validates any colors (reusing [parseSvgColor] — hex or CSS name), then
|
||||
/// publishes on the `icon` MessageBus channel. The Claude extension injects the
|
||||
/// matching card. Honest userError on an unknown glyph, a malformed/missing
|
||||
/// `--file`, or an unparseable color; toolError when there is no live UI.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../svg/svg_color.dart' show parseSvgColor;
|
||||
import 'dispatcher.dart';
|
||||
import 'ui_command.dart' show MessagePublisher;
|
||||
|
||||
/// Resolves a Phosphor glyph name to its codepoint, or null if unknown. Injected
|
||||
/// (wired to `kPhosphorGlyphs` in main.dart) so this file stays Flutter-free.
|
||||
typedef IconResolver = int? Function(String name);
|
||||
|
||||
/// Reads a metadata JSON file's contents, or null if unreadable. Injected for
|
||||
/// testability.
|
||||
typedef IconFileReader = Future<String?> Function(String path);
|
||||
|
||||
/// The MessageBus channel `icon.show` publishes on; the Claude extension
|
||||
/// subscribes to the same literal to inject the card.
|
||||
const iconShowChannel = 'icon';
|
||||
|
||||
void registerIconCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {required IconResolver resolve, IconFileReader? readFile}) {
|
||||
d.register(
|
||||
'icon.show',
|
||||
(req) async => _show(req, publisher, resolve, readFile),
|
||||
schema: const CommandSchema(
|
||||
positional: ['icons'],
|
||||
args: {
|
||||
'icons': ArgSpec(type: ArgType.stringList, rejectLeadingDash: true),
|
||||
'file': ArgSpec(rejectLeadingDash: true),
|
||||
'color': ArgSpec(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, IconResolver resolve, IconFileReader? readFile) async {
|
||||
final defaultColor = _str(req.args['color']);
|
||||
if (defaultColor != null && parseSvgColor(defaultColor) == null) {
|
||||
return _userErr(req.id, 'invalid color: $defaultColor', hint: '#rrggbb, #rrggbbaa, or a CSS color name');
|
||||
}
|
||||
|
||||
final entries = <Map<String, Object?>>[];
|
||||
// The entry payload comes from a piped --stdin (T-315) or a --file; --stdin
|
||||
// wins. Either is a JSON array of {icon,label,description,color}.
|
||||
final stdin = _str(req.args['stdin']);
|
||||
final file = _str(req.args['file']);
|
||||
String? payload = stdin;
|
||||
if (payload == null && file != null) {
|
||||
payload = readFile == null ? null : await readFile(file);
|
||||
if (payload == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such file: $file',
|
||||
hint: 'path is resolved relative to the workspace root',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload != null) {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(payload);
|
||||
} on FormatException catch (e) {
|
||||
return _userErr(req.id, 'invalid JSON in the icon payload: ${e.message}');
|
||||
}
|
||||
if (decoded is! List) return _userErr(req.id, 'icon metadata must be a JSON array of entries');
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) return _userErr(req.id, 'each icon entry must be a JSON object');
|
||||
final token = _str(item['icon']);
|
||||
if (token == null) return _userErr(req.id, 'each entry needs an "icon" name or 0x codepoint');
|
||||
final cp = _resolveIcon(token, resolve);
|
||||
if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint');
|
||||
final color = _str(item['color']);
|
||||
if (color != null && parseSvgColor(color) == null) return _userErr(req.id, 'invalid color: $color', hint: '#rrggbb, #rrggbbaa, or a CSS color name');
|
||||
entries.add({'codepoint': cp, 'name': token, 'label': ?_str(item['label']), 'description': ?_str(item['description']), 'color': ?color});
|
||||
}
|
||||
} else {
|
||||
final icons = (req.args['icons'] as List?)?.whereType<String>() ?? const <String>[];
|
||||
for (final token in icons) {
|
||||
final cp = _resolveIcon(token, resolve);
|
||||
if (cp == null) return _userErr(req.id, 'unknown icon: $token', hint: 'a kebab-case Phosphor name (e.g. gear) or a 0xNNNN codepoint');
|
||||
entries.add({'codepoint': cp, 'name': token});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return _userErr(req.id, 'at least one icon is required (e.g. `icon show gear folder` or `icon show --file icons.json`)');
|
||||
}
|
||||
|
||||
final publish = publisherSource();
|
||||
if (publish == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
publish('cli', iconShowChannel, {'entries': entries, 'color': ?defaultColor});
|
||||
return IpcResponse.ok(id: req.id, data: {'shown': true, 'count': entries.length});
|
||||
}
|
||||
|
||||
/// Resolve an icon [token] — a `0xNNNN` codepoint or a glyph name — to a
|
||||
/// codepoint, or null if it doesn't parse / resolve.
|
||||
int? _resolveIcon(String token, IconResolver resolve) {
|
||||
final t = token.trim();
|
||||
if (t.startsWith('0x') || t.startsWith('0X')) return int.tryParse(t.substring(2), radix: 16);
|
||||
return resolve(t);
|
||||
}
|
||||
|
||||
/// Trimmed non-empty string, or null — for tolerant arg/JSON reads.
|
||||
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
|
||||
@@ -14,6 +14,8 @@
|
||||
/// `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
@@ -30,20 +32,26 @@ const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
|
||||
/// the real filesystem; main.dart wires it to the workspace root + `File`.
|
||||
typedef ImagePathResolver = String? Function(String path);
|
||||
|
||||
/// Reads a metadata JSON file's contents, or null if unreadable. Injected so
|
||||
/// this file stays Flutter-free and unit-testable (T-316).
|
||||
typedef ImageFileReader = Future<String?> Function(String path);
|
||||
|
||||
/// The MessageBus channel `image.show` publishes on; the Claude extension
|
||||
/// subscribes to the same literal to inject the card. Kept here next to the
|
||||
/// publisher so both ends point at one name.
|
||||
const imageShowChannel = 'image';
|
||||
|
||||
void registerImageCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {ImagePathResolver? resolve}) {
|
||||
void registerImageCommands(DaemonDispatcher d, MessagePublisher? Function() publisher, {ImagePathResolver? resolve, ImageFileReader? readFile}) {
|
||||
d.register(
|
||||
'image.show',
|
||||
(req) async => _show(req, publisher, resolve),
|
||||
(req) async => _show(req, publisher, resolve, readFile),
|
||||
schema: const CommandSchema(
|
||||
positional: ['path'],
|
||||
args: {
|
||||
'path': ArgSpec(required: true, rejectLeadingDash: true),
|
||||
// Not required — the path may instead come from a --file payload (T-316).
|
||||
'path': ArgSpec(rejectLeadingDash: true),
|
||||
'caption': ArgSpec(),
|
||||
'file': ArgSpec(rejectLeadingDash: true),
|
||||
'fullscreen': ArgSpec(type: ArgType.boolean),
|
||||
},
|
||||
),
|
||||
@@ -55,10 +63,47 @@ IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.e
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve) async {
|
||||
final path = req.args['path'] as String?;
|
||||
Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisherSource, ImagePathResolver? resolve, ImageFileReader? readFile) async {
|
||||
String? path = req.args['path'] as String?;
|
||||
String? label, description;
|
||||
String? caption = req.args['caption'] as String?;
|
||||
|
||||
// An annotation payload {path,label,description,caption} from a piped --stdin
|
||||
// (T-315) or a --file (T-316); --stdin wins. Additive — the bare
|
||||
// `image show <path> [--caption]` form is unchanged.
|
||||
final stdin = _str(req.args['stdin']);
|
||||
final file = _str(req.args['file']);
|
||||
String? payload = stdin;
|
||||
if (payload == null && file != null) {
|
||||
payload = readFile == null ? null : await readFile(file);
|
||||
if (payload == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such file: $file',
|
||||
hint: 'path is resolved relative to the workspace root',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (payload != null) {
|
||||
Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(payload);
|
||||
} on FormatException catch (e) {
|
||||
return _userErr(req.id, 'invalid JSON in the image payload: ${e.message}');
|
||||
}
|
||||
if (decoded is! Map) return _userErr(req.id, 'image metadata must be a JSON object');
|
||||
path = _str(decoded['path']) ?? path;
|
||||
label = _str(decoded['label']);
|
||||
description = _str(decoded['description']);
|
||||
caption = _str(decoded['caption']) ?? caption;
|
||||
}
|
||||
|
||||
if (path == null || path.trim().isEmpty) {
|
||||
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)');
|
||||
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png` or `image show --file meta.json`)');
|
||||
}
|
||||
|
||||
final ext = _extensionOf(path);
|
||||
@@ -86,8 +131,6 @@ Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisher
|
||||
resolved = abs;
|
||||
}
|
||||
|
||||
final caption = req.args['caption'] as String?;
|
||||
|
||||
final publish = publisherSource();
|
||||
if (publish == null) {
|
||||
// No live UI bus — headless / CLI-only context. Honest failure, not a hang.
|
||||
@@ -100,11 +143,19 @@ Future<IpcResponse> _show(IpcRequest req, MessagePublisher? Function() publisher
|
||||
publish('cli', imageShowChannel, {
|
||||
'path': resolved,
|
||||
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
|
||||
'label': ?label,
|
||||
'description': ?description,
|
||||
if (fullscreen) 'fullscreen': true,
|
||||
});
|
||||
return IpcResponse.ok(id: req.id, data: {'path': resolved, 'caption': ?caption, 'fullscreen': fullscreen, 'shown': true});
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {'path': resolved, 'caption': ?caption, 'label': ?label, 'description': ?description, 'fullscreen': fullscreen, 'shown': true},
|
||||
);
|
||||
}
|
||||
|
||||
/// Trimmed non-empty string, or null — for tolerant JSON field reads.
|
||||
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
|
||||
|
||||
/// Lower-cased extension (without the dot) of [path], or '' if none.
|
||||
String _extensionOf(String path) {
|
||||
final slash = path.lastIndexOf('/');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/// Registers the `instance` command — this running clide's identity in one
|
||||
/// round-trip (T-247): version, pid, workspace root, and socket path. It's the
|
||||
/// per-instance metadata the `clide instances` CLI verb aggregates (by probing
|
||||
/// every live socket in the runtime dir), and it lets a human or agent confirm
|
||||
/// *which* instance a given socket belongs to.
|
||||
///
|
||||
/// Thin + Flutter-free: the caller (main.dart) passes the values it already
|
||||
/// holds at dispatcher-build time, so this stays trivially testable.
|
||||
library;
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
void registerInstanceCommand(DaemonDispatcher d, {required String version, required int pid, required String workspace, required String socketPath}) {
|
||||
d.register('instance', (req) async => IpcResponse.ok(id: req.id, data: {'version': version, 'pid': pid, 'workspace': workspace, 'socketPath': socketPath}));
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/// `clide project new <name> [--dir <parent>]` — create a new clide project
|
||||
/// (T-487, story T-486). clide treats a git repo as the workspace, so a new
|
||||
/// project is: a fresh directory, `git init`, and a minimal scaffold. The git
|
||||
/// binary lives behind the toolchain in main.dart, so it's injected here as
|
||||
/// [ProjectGitInit], keeping this handler Flutter-free (runs under `dart test`).
|
||||
///
|
||||
/// This verb creates only — opening the new workspace and the account roadblock
|
||||
/// (T-488) are the UI flow's job; the CLI returns the created path.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// MessageBus channel announcing a freshly-created project (path in `dir`). The
|
||||
/// welcome new-project dialog publishes it after create + open; the Claude
|
||||
/// extension consumes it to show the per-repo account roadblock (T-488) — only
|
||||
/// NEW projects prompt, existing opens never do. Lives here in the neutral
|
||||
/// daemon layer so both builtins name one literal without coupling to each other.
|
||||
const projectCreatedChannel = 'project.created';
|
||||
|
||||
/// Runs `git init` in [dir]. Injected so this stays Flutter-free + testable;
|
||||
/// main.dart wires it to the real toolchain.
|
||||
typedef ProjectGitInit = Future<void> Function(String dir);
|
||||
|
||||
/// Outcome of a [createNewProject] attempt — the created path, or a clear error.
|
||||
class NewProjectResult {
|
||||
const NewProjectResult.ok(String this.path) : error = null;
|
||||
const NewProjectResult.err(String this.error) : path = null;
|
||||
final String? path;
|
||||
final String? error;
|
||||
bool get ok => error == null;
|
||||
}
|
||||
|
||||
/// Validate a project name: a single folder segment, not a path or a dot-name.
|
||||
String? validateProjectName(String name) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return 'project name is required';
|
||||
if (n.contains('/') || n.contains(r'\')) return 'name must be a single folder, not a path';
|
||||
if (n == '.' || n == '..' || n.startsWith('.')) return 'invalid project name: "$name"';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Create `<parent>/<name>/`, `git init` it (via [gitInit]), and write a minimal
|
||||
/// scaffold. Never overwrites an existing entry. Returns the created path or a
|
||||
/// clear, user-facing error.
|
||||
Future<NewProjectResult> createNewProject({required String parent, required String name, required ProjectGitInit gitInit}) async {
|
||||
final nameErr = validateProjectName(name);
|
||||
if (nameErr != null) return NewProjectResult.err(nameErr);
|
||||
|
||||
final trimmedParent = _stripTrailingSep(parent.trim());
|
||||
if (trimmedParent.isEmpty) return NewProjectResult.err('a parent directory is required');
|
||||
if (!Directory(trimmedParent).existsSync()) return NewProjectResult.err('parent directory does not exist: $trimmedParent');
|
||||
|
||||
final target = '$trimmedParent/${name.trim()}';
|
||||
if (Directory(target).existsSync() || File(target).existsSync()) {
|
||||
return NewProjectResult.err('already exists: $target');
|
||||
}
|
||||
|
||||
Directory(target).createSync(recursive: true);
|
||||
await gitInit(target);
|
||||
_writeScaffold(target, name.trim());
|
||||
return NewProjectResult.ok(target);
|
||||
}
|
||||
|
||||
/// `git init` an EXISTING folder that isn't a repo yet (T-489) — the "initialize
|
||||
/// this folder as a clide project" path. Unlike [createNewProject] it never
|
||||
/// creates the dir and never clobbers existing files; the scaffold is only
|
||||
/// written where absent.
|
||||
Future<NewProjectResult> initExistingProject({required String path, required ProjectGitInit gitInit}) async {
|
||||
final trimmed = _stripTrailingSep(path.trim());
|
||||
if (trimmed.isEmpty) return NewProjectResult.err('a directory is required');
|
||||
if (!Directory(trimmed).existsSync()) return NewProjectResult.err('directory does not exist: $trimmed');
|
||||
await gitInit(trimmed);
|
||||
_writeScaffold(trimmed, trimmed.split('/').where((s) => s.isNotEmpty).lastOrNull ?? 'project');
|
||||
return NewProjectResult.ok(trimmed);
|
||||
}
|
||||
|
||||
void _writeScaffold(String dir, String name) {
|
||||
// Minimal + non-prescriptive: keep clide's own state out of git, and orient
|
||||
// Claude with an empty CLAUDE.md stub. No language/framework templates. Never
|
||||
// clobbers — safe to run over an existing folder (T-489).
|
||||
final gitignore = File('$dir/.gitignore');
|
||||
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n');
|
||||
final claudeMd = File('$dir/CLAUDE.md');
|
||||
if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
|
||||
}
|
||||
|
||||
String _stripTrailingSep(String p) {
|
||||
var s = p;
|
||||
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/// Register `project.new` + `project.init`. [gitInit] runs git init;
|
||||
/// [defaultParent] supplies the new-project parent when `--dir` is omitted (the
|
||||
/// current workspace's parent); [defaultInitPath] supplies the init target when
|
||||
/// `--dir` is omitted (the current workspace, so `clide project init` inits the
|
||||
/// folder you're in).
|
||||
void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent, String? Function()? defaultInitPath}) {
|
||||
d.register('project.new', (req) async {
|
||||
final name = (req.args['name'] as String?)?.trim();
|
||||
if (name == null || name.isEmpty) return _err(req.id, 'project new requires a <name>');
|
||||
final parent = (req.args['dir'] as String?)?.trim() ?? defaultParent?.call();
|
||||
if (parent == null || parent.isEmpty) {
|
||||
return _err(req.id, 'no parent directory to create in', hint: 'pass --dir <parent>');
|
||||
}
|
||||
final result = await createNewProject(parent: parent, name: name, gitInit: gitInit);
|
||||
if (!result.ok) return _err(req.id, result.error!);
|
||||
return IpcResponse.ok(id: req.id, data: {'path': result.path, 'name': name});
|
||||
}, schema: const CommandSchema(positional: ['name'], args: {'name': ArgSpec(required: true, rejectLeadingDash: true), 'dir': ArgSpec()}));
|
||||
|
||||
d.register('project.init', (req) async {
|
||||
final path = (req.args['dir'] as String?)?.trim() ?? defaultInitPath?.call();
|
||||
if (path == null || path.isEmpty) return _err(req.id, 'no directory to initialize', hint: 'pass --dir <path>');
|
||||
final result = await initExistingProject(path: path, gitInit: gitInit);
|
||||
if (!result.ok) return _err(req.id, result.error!);
|
||||
return IpcResponse.ok(id: req.id, data: {'path': result.path});
|
||||
}, schema: const CommandSchema(args: {'dir': ArgSpec()}));
|
||||
}
|
||||
|
||||
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
/// The `compare` drawing-card template (T-319 / D-91 / D-103).
|
||||
///
|
||||
/// A before/after (or N-up) comparison: the doc's `images` array — each
|
||||
/// `{path, label, description}` — lowers to an SVG of side-by-side `<image>`
|
||||
/// cells, every cell carrying the per-object `data-label` / `data-description`
|
||||
/// (the T-318 caption overlay) and `data-lightbox` (tap-to-zoom, shared with the
|
||||
/// image template). The same renderer (T-320) paints it; the painter aspect-fits
|
||||
/// each image into its cell so differing shapes don't distort.
|
||||
///
|
||||
/// Paths are resolved to absolute up front via an injected resolver (like
|
||||
/// image.show), so the card loader just reads the file — and an unresolvable
|
||||
/// path is an honest [DrawErr], not a broken cell.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'draw_dispatch.dart';
|
||||
|
||||
/// Resolves a user-supplied image path to an absolute existing path, or null.
|
||||
typedef ComparePathResolver = String? Function(String path);
|
||||
|
||||
const _cellW = 320, _cellH = 240, _gap = 20, _capH = 56;
|
||||
|
||||
/// Handler for `template: "compare"` — reads the doc's `images` array. Register
|
||||
/// this in the [DrawingRegistry] with a path [resolvePath] (wired to the
|
||||
/// workspace root in main.dart).
|
||||
DrawingTemplateHandler compareTemplateHandler({required ComparePathResolver resolvePath}) {
|
||||
return (doc) async {
|
||||
final images = doc.fields['images'];
|
||||
if (images is! List || images.isEmpty) {
|
||||
return const DrawErr('the compare template needs a non-empty "images" array of {path,label,description}');
|
||||
}
|
||||
final cells = StringBuffer();
|
||||
for (var i = 0; i < images.length; i++) {
|
||||
final item = images[i];
|
||||
if (item is! Map) return const DrawErr('each compare image must be an object with a "path"');
|
||||
final path = _str(item['path']);
|
||||
if (path == null) return const DrawErr('each compare image needs a "path"');
|
||||
final abs = resolvePath(path);
|
||||
if (abs == null) return DrawErr('no such image: $path');
|
||||
final x = i * (_cellW + _gap);
|
||||
final label = _str(item['label']);
|
||||
final desc = _str(item['description']);
|
||||
cells.write('<image href="${_esc(abs)}" x="$x" y="0" width="$_cellW" height="$_cellH"');
|
||||
if (label != null) cells.write(' data-label="${_esc(label)}"');
|
||||
if (desc != null) cells.write(' data-description="${_esc(desc)}"');
|
||||
cells.write(' data-lightbox=""/>');
|
||||
}
|
||||
final n = images.length;
|
||||
final totalW = n * _cellW + (n - 1) * _gap;
|
||||
return DrawOk('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 $totalW ${_cellH + _capH}">$cells</svg>');
|
||||
};
|
||||
}
|
||||
|
||||
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
|
||||
|
||||
String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
@@ -0,0 +1,80 @@
|
||||
/// The `d2` drawing-card template (T-494 / D-91 / D-103).
|
||||
///
|
||||
/// A d2 diagram is just an SVG card with a compile step in front: the doc's
|
||||
/// `source` (d2 diagram text) is compiled to SVG, then painted by the SAME
|
||||
/// renderer the `svg` card uses (T-320). The compile shells out to the `d2`
|
||||
/// binary — the supporter-tool pattern (peer of pql/git, D-3/D-5), resolved via
|
||||
/// the D-104 path layer (T-495). No second core language, no vendored Go.
|
||||
///
|
||||
/// Honest failures (D-103): a missing source, an unresolved `d2`, or a compile
|
||||
/// error each return a [DrawErr] with a user-facing message + hint, which the
|
||||
/// command layer turns into an IpcError userError — never a throw.
|
||||
///
|
||||
/// Flutter-free: pure Dart (dart:io), runs under `dart test`. The process spawn
|
||||
/// is injectable ([D2Compiler]) so the handler is tested without a real binary.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../env/supporter_binaries.dart';
|
||||
import 'draw_dispatch.dart';
|
||||
|
||||
/// Compiles d2 [source] to an SVG [DrawResult]. Injected into
|
||||
/// [d2TemplateHandler] so it is testable; the default is [d2CompileViaBinary].
|
||||
typedef D2Compiler = Future<DrawResult> Function(String source);
|
||||
|
||||
/// Handler for `template: "d2"` — reads the doc's `source` field (the diagram
|
||||
/// text) and compiles it. Register this in the [DrawingRegistry].
|
||||
DrawingTemplateHandler d2TemplateHandler({D2Compiler compile = d2CompileViaBinary}) {
|
||||
return (doc) async {
|
||||
final source = doc.fields['source'];
|
||||
if (source is! String || source.trim().isEmpty) {
|
||||
return const DrawErr('the d2 template needs a non-empty "source" field (the d2 diagram text)');
|
||||
}
|
||||
return compile(source);
|
||||
};
|
||||
}
|
||||
|
||||
/// One run of the d2 binary: its exit code, stdout (SVG) and stderr.
|
||||
typedef D2RunResult = ({int code, String out, String err});
|
||||
|
||||
/// Runs the d2 [exe] over [source]. Injected so [d2CompileViaBinary] is tested
|
||||
/// without a real binary; the default is [_spawnD2].
|
||||
typedef D2Run = Future<D2RunResult> Function(String exe, String source);
|
||||
|
||||
/// Resolve the `d2` binary (D-104) and compile [source] through it. Failure keys
|
||||
/// off the exit code — d2 logs `success:` to stderr on a clean compile, so a
|
||||
/// non-empty stderr is not itself an error. [resolveD2] and [run] are injectable
|
||||
/// for testing; the defaults use [activeSupporterBinaries] and a real spawn.
|
||||
Future<DrawResult> d2CompileViaBinary(String source, {String? Function()? resolveD2, D2Run run = _spawnD2}) async {
|
||||
final d2 = (resolveD2 ?? _defaultResolveD2)();
|
||||
if (d2 == null) {
|
||||
return const DrawErr('d2 not found — install it from https://d2lang.com, or set its path in Settings → Tools');
|
||||
}
|
||||
final D2RunResult r;
|
||||
try {
|
||||
r = await run(d2, source);
|
||||
} catch (e) {
|
||||
return DrawErr('could not run d2 ($d2): $e');
|
||||
}
|
||||
if (r.code != 0) {
|
||||
return DrawErr('d2 compile failed: ${r.err.trim().isEmpty ? 'exit ${r.code}' : r.err.trim()}');
|
||||
}
|
||||
if (r.out.trim().isEmpty) return const DrawErr('d2 produced no SVG');
|
||||
return DrawOk(r.out);
|
||||
}
|
||||
|
||||
String? _defaultResolveD2() => (activeSupporterBinaries ?? SupporterBinaries()).resolve('d2');
|
||||
|
||||
/// `d2 - -` — read source on stdin, write SVG to stdout. Drains stdout/stderr
|
||||
/// concurrently with the stdin write to avoid a pipe deadlock on a big diagram.
|
||||
Future<D2RunResult> _spawnD2(String exe, String source) async {
|
||||
final proc = await Process.start(exe, const ['-', '-']);
|
||||
final outF = proc.stdout.transform(utf8.decoder).join();
|
||||
final errF = proc.stderr.transform(utf8.decoder).join();
|
||||
proc.stdin.write(source);
|
||||
await proc.stdin.close();
|
||||
final code = await proc.exitCode;
|
||||
return (code: code, out: await outF, err: await errF);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Drawing-card template dispatch (T-318 / D-103).
|
||||
///
|
||||
/// Lowers a [DrawingCardDoc] to an SVG string — the substrate the renderer
|
||||
/// paints (D-103). In primitive mode the SVG is the doc's inline `svg` or the
|
||||
/// contents of its `svgPath`; in template mode a registered handler (`d2`,
|
||||
/// `icon`, `compare`, `image` — the child tickets) produces the SVG from the
|
||||
/// doc's fields. The handlers and the file reader are injected, so this stays
|
||||
/// headless- and `dart test`-friendly (no ambient filesystem, mirroring how
|
||||
/// image.show injects its path resolver).
|
||||
///
|
||||
/// Honest result: every failure (no source, unknown template, unreadable path,
|
||||
/// empty handler output) returns a [DrawErr] with a message rather than
|
||||
/// throwing — the command layer turns it into an IpcError userError.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'draw_doc.dart';
|
||||
|
||||
/// Lowers a template-mode doc to SVG, as a [DrawResult] — [DrawOk] with the SVG
|
||||
/// or [DrawErr] carrying an honest, user-facing message (e.g. a compile failure
|
||||
/// or an unresolved tool, with a hint).
|
||||
typedef DrawingTemplateHandler = Future<DrawResult> Function(DrawingCardDoc doc);
|
||||
|
||||
/// Reads a file's contents, or `null` if unreadable. Injected for testability.
|
||||
typedef DrawingFileReader = Future<String?> Function(String path);
|
||||
|
||||
/// Registry of template handlers, keyed by `template` name.
|
||||
class DrawingRegistry {
|
||||
final Map<String, DrawingTemplateHandler> _handlers = {};
|
||||
|
||||
/// Register (or replace) the handler for [template].
|
||||
void register(String template, DrawingTemplateHandler handler) => _handlers[template] = handler;
|
||||
|
||||
DrawingTemplateHandler? handlerFor(String template) => _handlers[template];
|
||||
|
||||
bool get isEmpty => _handlers.isEmpty;
|
||||
}
|
||||
|
||||
/// Outcome of lowering a doc to SVG.
|
||||
sealed class DrawResult {
|
||||
const DrawResult();
|
||||
}
|
||||
|
||||
class DrawOk extends DrawResult {
|
||||
const DrawOk(this.svg);
|
||||
final String svg;
|
||||
}
|
||||
|
||||
class DrawErr extends DrawResult {
|
||||
const DrawErr(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Lower [doc] to an SVG string. Primitive docs use their inline `svg` or read
|
||||
/// `svgPath` via [readFile]; template docs use the matching handler in
|
||||
/// [registry].
|
||||
Future<DrawResult> resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registry, {required DrawingFileReader readFile}) async {
|
||||
if (doc.isPrimitive) {
|
||||
if (doc.svg != null) return DrawOk(doc.svg!);
|
||||
if (doc.svgPath != null) {
|
||||
final contents = await readFile(doc.svgPath!);
|
||||
return contents == null ? DrawErr('cannot read ${doc.svgPath}') : DrawOk(contents);
|
||||
}
|
||||
return const DrawErr('drawing card has no svg, svgPath, or template');
|
||||
}
|
||||
|
||||
final handler = registry.handlerFor(doc.template!);
|
||||
if (handler == null) return DrawErr('unknown drawing template: ${doc.template}');
|
||||
return handler(doc);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// The drawing-card document model + envelope parser (T-318 / D-91 / D-103).
|
||||
///
|
||||
/// A `clide draw` payload is a JSON document. In PRIMITIVE mode it carries raw
|
||||
/// SVG (`svg` inline or `svgPath`); in TEMPLATE mode it names a `template`
|
||||
/// (`d2`, `icon`, `compare`, `image`, …) whose fields a registered handler
|
||||
/// lowers to SVG. Either way an optional card-level `label`/`description`
|
||||
/// renders as a caption beneath the drawing (D-103: SVG is the substrate; a thin
|
||||
/// Flutter overlay carries the chrome).
|
||||
///
|
||||
/// This is just the typed envelope + a tolerant parser — template lowering and
|
||||
/// painting live elsewhere. Never throws; a non-object payload yields `null`.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
/// A parsed drawing-card document.
|
||||
class DrawingCardDoc {
|
||||
const DrawingCardDoc({this.label, this.description, this.template, this.svg, this.svgPath, this.fields = const {}});
|
||||
|
||||
/// Card-level caption, shown beneath the drawing when present.
|
||||
final String? label, description;
|
||||
|
||||
/// Template name (`d2`, `icon`, …). `null` or `svg` ⇒ primitive mode.
|
||||
final String? template;
|
||||
|
||||
/// Primitive-mode SVG: inline source, or a path to an `.svg`.
|
||||
final String? svg, svgPath;
|
||||
|
||||
/// The full document map, so a template handler can read its own fields
|
||||
/// (`source`, `items`, `path`, …).
|
||||
final Map<String, Object?> fields;
|
||||
|
||||
/// True when the card is raw SVG rather than a named template.
|
||||
bool get isPrimitive => template == null || template == 'svg';
|
||||
}
|
||||
|
||||
/// Parse a decoded-JSON drawing-card document (a `Map`). Returns `null` only
|
||||
/// when [json] isn't a JSON object; never throws.
|
||||
DrawingCardDoc? parseDrawingCardDoc(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final card = json['card'];
|
||||
final cardMap = card is Map ? card : const {};
|
||||
return DrawingCardDoc(
|
||||
label: _str(cardMap['label']) ?? _str(json['label']),
|
||||
description: _str(cardMap['description']) ?? _str(json['description']),
|
||||
template: _str(json['template']),
|
||||
svg: _str(json['svg']),
|
||||
svgPath: _str(json['svgPath']),
|
||||
fields: {
|
||||
for (final e in json.entries)
|
||||
if (e.key is String) e.key as String: e.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String? _str(Object? v) => v is String && v.isNotEmpty ? v : null;
|
||||
@@ -0,0 +1,87 @@
|
||||
/// The `graph` drawing-card template (T-321 / D-91 / D-103).
|
||||
///
|
||||
/// Lowers a `{nodes:[{id,label}], edges:[{from,to}]}` payload to an SVG the
|
||||
/// shared renderer (T-320) paints: a deterministic circular layout — nodes on a
|
||||
/// ring as labelled `<circle>`s, edges as `<line>`s between them. Display-only
|
||||
/// per D-78; the card-level label/description (T-318) renders beneath. Distinct
|
||||
/// from the interactive force-directed graph PANE (T-323) — this is a static
|
||||
/// graph dropped into the conversation.
|
||||
///
|
||||
/// Self-contained like a d2 diagram: it carries its own light backdrop + content
|
||||
/// colors (the graph is content, not clide chrome — its own palette). Honest
|
||||
/// [DrawErr] on an empty node set, a duplicate id, or an edge to an unknown node.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'draw_dispatch.dart';
|
||||
|
||||
const _nodeR = 9.0; // node circle radius
|
||||
const _ringR = 140.0; // layout ring radius
|
||||
const _pad = 52.0; // room for labels around the ring
|
||||
|
||||
DrawingTemplateHandler graphTemplateHandler() {
|
||||
return (doc) async {
|
||||
final nodesRaw = doc.fields['nodes'];
|
||||
if (nodesRaw is! List || nodesRaw.isEmpty) {
|
||||
return const DrawErr('the graph template needs a non-empty "nodes" array of {id,label}');
|
||||
}
|
||||
|
||||
final ids = <String>[];
|
||||
final labels = <String>[];
|
||||
for (final node in nodesRaw) {
|
||||
if (node is! Map) return const DrawErr('each graph node must be a JSON object {id,label}');
|
||||
final id = _str(node['id']);
|
||||
if (id == null) return const DrawErr('each graph node needs an "id"');
|
||||
if (ids.contains(id)) return DrawErr('duplicate node id: $id');
|
||||
ids.add(id);
|
||||
labels.add(_str(node['label']) ?? id);
|
||||
}
|
||||
|
||||
final n = ids.length;
|
||||
final cx = _ringR + _pad, cy = _ringR + _pad;
|
||||
final px = List<double>.filled(n, cx), py = List<double>.filled(n, cy);
|
||||
for (var i = 0; i < n && n > 1; i++) {
|
||||
final a = -math.pi / 2 + 2 * math.pi * i / n; // start at top, clockwise
|
||||
px[i] = cx + _ringR * math.cos(a);
|
||||
py[i] = cy + _ringR * math.sin(a);
|
||||
}
|
||||
final index = {for (var i = 0; i < n; i++) ids[i]: i};
|
||||
|
||||
// Edges first so nodes paint on top of them.
|
||||
final edges = StringBuffer();
|
||||
final edgesRaw = doc.fields['edges'];
|
||||
if (edgesRaw is List) {
|
||||
for (final edge in edgesRaw) {
|
||||
if (edge is! Map) return const DrawErr('each graph edge must be a JSON object {from,to}');
|
||||
final from = _str(edge['from']), to = _str(edge['to']);
|
||||
if (from == null || to == null) return const DrawErr('each graph edge needs a "from" and a "to"');
|
||||
final fi = index[from], ti = index[to];
|
||||
if (fi == null) return DrawErr('edge references unknown node: $from');
|
||||
if (ti == null) return DrawErr('edge references unknown node: $to');
|
||||
edges.write('<line x1="${_fmt(px[fi])}" y1="${_fmt(py[fi])}" x2="${_fmt(px[ti])}" y2="${_fmt(py[ti])}" stroke="#9aa4b2" stroke-width="1.5"/>');
|
||||
}
|
||||
}
|
||||
|
||||
final nodes = StringBuffer();
|
||||
for (var i = 0; i < n; i++) {
|
||||
nodes.write('<circle cx="${_fmt(px[i])}" cy="${_fmt(py[i])}" r="$_nodeR" fill="#4a90d9"/>');
|
||||
nodes.write('<text x="${_fmt(px[i])}" y="${_fmt(py[i] - _nodeR - 5)}" text-anchor="middle" font-size="13" fill="#33373d">${_esc(labels[i])}</text>');
|
||||
}
|
||||
|
||||
final size = 2 * (_ringR + _pad);
|
||||
return DrawOk(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${_fmt(size)} ${_fmt(size)}">'
|
||||
'<rect width="${_fmt(size)}" height="${_fmt(size)}" fill="#fafafa"/>'
|
||||
'$edges$nodes</svg>',
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
|
||||
|
||||
String _fmt(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
|
||||
|
||||
String _esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
@@ -180,8 +180,13 @@ class EditorRegistry {
|
||||
if (buf == null) return;
|
||||
_pathToId.remove(buf.path);
|
||||
if (_activeId == id) {
|
||||
// Promote the next buffer, or clear to null when this was the last one.
|
||||
// Emit active-changed in BOTH cases: a null id is the signal the editor
|
||||
// split collapses on (T-459). Guarding the emit on `_activeId != null`
|
||||
// suppressed exactly the last-buffer-closed event, leaving editorOpen
|
||||
// stuck true and the top split orphaned over the primary pane.
|
||||
_activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id;
|
||||
if (_activeId != null) _emitActive();
|
||||
_emitActive();
|
||||
}
|
||||
_emit('editor.closed', {'id': id, 'path': buf.path});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/// Resolves external supporter binaries (claude, d2, …) to absolute paths
|
||||
/// (T-495, D-104).
|
||||
///
|
||||
/// Order: an explicit user-scope **override** map is consulted FIRST; then the
|
||||
/// login-shell / process search PATH ([resolvedToolPath]); then the well-known
|
||||
/// user/local bin dirs — including **Homebrew-on-Linux** (`/home/linuxbrew/...`),
|
||||
/// which the standard PATH expansion omits and a login-shell probe misses when
|
||||
/// `brew shellenv` lives only in `~/.bashrc`. That omission is the gap D-104
|
||||
/// fixes.
|
||||
///
|
||||
/// [detect] probes those locations to seed the override map on first run (or a
|
||||
/// re-detect), so resolution is **pinned** thereafter — deterministic, not a
|
||||
/// per-launch heuristic. A pin that no longer points at a file is reported by
|
||||
/// [isStalePin] so the caller can warn + re-detect rather than silently fail.
|
||||
///
|
||||
/// Pure Dart (injected `exists` / `searchPath`), Flutter-free; runs under
|
||||
/// `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'shell_env.dart' show resolvedToolPath;
|
||||
|
||||
/// Whether [absolutePath] points at an existing file (symlinks followed).
|
||||
typedef PathExists = bool Function(String absolutePath);
|
||||
|
||||
class SupporterBinaries {
|
||||
SupporterBinaries({Map<String, String> overrides = const {}, PathExists? exists, String Function()? searchPath, String? home, bool isWindows = false})
|
||||
: _overrides = overrides,
|
||||
_exists = exists ?? _fileExists,
|
||||
_searchPath = searchPath ?? resolvedToolPath,
|
||||
_home = home ?? Platform.environment['HOME'],
|
||||
_sep = isWindows ? ';' : ':';
|
||||
|
||||
final Map<String, String> _overrides;
|
||||
final PathExists _exists;
|
||||
final String Function() _searchPath;
|
||||
final String? _home;
|
||||
final String _sep;
|
||||
|
||||
/// Resolve [name] to an absolute path, or `null`. Order: explicit override (if
|
||||
/// it still exists) → search PATH → well-known dirs.
|
||||
String? resolve(String name) {
|
||||
final pinned = _overrides[name];
|
||||
if (pinned != null && pinned.isNotEmpty && _exists(pinned)) return pinned;
|
||||
for (final dir in _searchDirs()) {
|
||||
final p = '$dir/$name';
|
||||
if (_exists(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// True when [name]'s override is set but no longer points at a file — a stale
|
||||
/// pin (the tool moved on an upgrade). The caller warns and can re-detect.
|
||||
bool isStalePin(String name) {
|
||||
final p = _overrides[name];
|
||||
return p != null && p.isNotEmpty && !_exists(p);
|
||||
}
|
||||
|
||||
/// Probe for [names], returning name→absolute-path for those found — the seed
|
||||
/// for the override map on first run / re-detect.
|
||||
Map<String, String> detect(Iterable<String> names) {
|
||||
final dirs = _searchDirs().toList();
|
||||
final out = <String, String>{};
|
||||
for (final name in names) {
|
||||
for (final dir in dirs) {
|
||||
final p = '$dir/$name';
|
||||
if (_exists(p)) {
|
||||
out[name] = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Iterable<String> _searchDirs() sync* {
|
||||
final seen = <String>{};
|
||||
for (final dir in _searchPath().split(_sep)) {
|
||||
if (dir.isNotEmpty && seen.add(dir)) yield dir;
|
||||
}
|
||||
for (final dir in _wellKnownDirs()) {
|
||||
if (seen.add(dir)) yield dir;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _wellKnownDirs() {
|
||||
final h = _home ?? '';
|
||||
return [if (h.isNotEmpty) '$h/.local/bin', '/usr/local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/home/linuxbrew/.linuxbrew/bin'];
|
||||
}
|
||||
}
|
||||
|
||||
bool _fileExists(String p) => File(p).existsSync();
|
||||
|
||||
/// The process-wide resolver, wired at boot via [loadSupporterBinaries]. Tool
|
||||
/// consumers (e.g. the d2 template, T-494) read it to resolve a binary; null in
|
||||
/// headless contexts where boot wiring hasn't run.
|
||||
SupporterBinaries? activeSupporterBinaries;
|
||||
|
||||
/// User-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding the
|
||||
/// explicit override path for tool [name]. One key per tool so the settings
|
||||
/// panel binds a plain text field to each (T-414 / D-104).
|
||||
String supporterToolKey(String name) => 'app.tools.$name';
|
||||
|
||||
/// Marker key recording that first-run auto-detection has run, so a later launch
|
||||
/// neither re-probes nor clobbers a path the user cleared on purpose.
|
||||
const supporterDetectedKey = 'app.tools.detected';
|
||||
|
||||
/// The external supporter binaries clide auto-detects (pql/git are bundled per
|
||||
/// D-58/D-59 and excluded).
|
||||
const knownSupporterTools = ['claude', 'd2'];
|
||||
|
||||
/// Build a [SupporterBinaries] from the per-tool override keys, auto-detecting
|
||||
/// the as-yet-unconfigured tools ONCE on first run and pinning what it finds
|
||||
/// (D-104). [read] returns a stored value (or null); [write] persists one.
|
||||
/// Injected (not the SettingsStore directly) so this stays Flutter-free and
|
||||
/// `dart test`-able; [detect] is the prober.
|
||||
Future<SupporterBinaries> loadSupporterBinaries({
|
||||
required Object? Function(String key) read,
|
||||
required Future<void> Function(String key, Object? value) write,
|
||||
List<String> tools = knownSupporterTools,
|
||||
SupporterBinaries Function()? detect,
|
||||
}) async {
|
||||
final overrides = _readOverrides(read, tools);
|
||||
if (read(supporterDetectedKey) == null) {
|
||||
// First run: probe the tools without an explicit path, pin what's found.
|
||||
final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools.where((t) => !overrides.containsKey(t)));
|
||||
for (final e in fresh.entries) {
|
||||
await write(supporterToolKey(e.key), e.value);
|
||||
overrides[e.key] = e.value;
|
||||
}
|
||||
await write(supporterDetectedKey, true);
|
||||
}
|
||||
return SupporterBinaries(overrides: overrides);
|
||||
}
|
||||
|
||||
/// Re-probe every tool and overwrite its override key (clearing one no longer
|
||||
/// found), returning a fresh resolver. Backs the "Re-detect" settings action.
|
||||
Future<SupporterBinaries> redetectSupporterBinaries({
|
||||
required Future<void> Function(String key, Object? value) write,
|
||||
List<String> tools = knownSupporterTools,
|
||||
SupporterBinaries Function()? detect,
|
||||
}) async {
|
||||
final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools);
|
||||
for (final t in tools) {
|
||||
await write(supporterToolKey(t), fresh[t]);
|
||||
}
|
||||
await write(supporterDetectedKey, true);
|
||||
return SupporterBinaries(overrides: fresh);
|
||||
}
|
||||
|
||||
/// A resolver built from the current per-tool override keys, with no detect or
|
||||
/// marker side effects — for rebuilding [activeSupporterBinaries] live when a
|
||||
/// path is edited in settings.
|
||||
SupporterBinaries supporterBinariesFrom(Object? Function(String key) read, {List<String> tools = knownSupporterTools}) =>
|
||||
SupporterBinaries(overrides: _readOverrides(read, tools));
|
||||
|
||||
Map<String, String> _readOverrides(Object? Function(String) read, List<String> tools) {
|
||||
final overrides = <String, String>{};
|
||||
for (final t in tools) {
|
||||
final v = read(supporterToolKey(t));
|
||||
if (v is String && v.isNotEmpty) overrides[t] = v;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
@@ -18,6 +18,18 @@ class GitClient {
|
||||
final ToolchainView toolchain;
|
||||
final Directory workDir;
|
||||
|
||||
/// Initialize a new git repository in [workDir] (T-487) — the backing of the
|
||||
/// new-project flow, since clide treats a git repo as the workspace. The
|
||||
/// directory must already exist. `-b <defaultBranch>` keeps the initial branch
|
||||
/// deterministic rather than dependent on the user's git config. Idempotent:
|
||||
/// `git init` on an existing repo is a no-op.
|
||||
Future<void> init({String defaultBranch = 'main'}) async {
|
||||
final r = await _run(['init', '-b', defaultBranch]);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git init failed', stderr: r.stderr.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// -- queries --------------------------------------------------------------
|
||||
|
||||
Future<GitStatus> status() async {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/// Force-directed graph layout (T-323) — a clide-owned Fruchterman-Reingold
|
||||
/// solver (own-the-rendering-stack: no layout package).
|
||||
///
|
||||
/// Nodes repel each other (an inverse-distance "Coulomb" force); edges pull
|
||||
/// their endpoints together (a "spring"). Iterating with a cooling temperature
|
||||
/// settles the graph into a readable layout. DETERMINISTIC — a fixed circular
|
||||
/// seed (no RNG) means the same graph always lays out identically, so the view
|
||||
/// is stable across rebuilds and the solver is unit-testable.
|
||||
///
|
||||
/// Flutter-free: pure Dart (dart:math), runs under `dart test`. The graph PANE
|
||||
/// (rendering, pan/zoom, hover, filter) builds on top of this.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// A laid-out 2D point.
|
||||
typedef GraphPoint = ({double x, double y});
|
||||
|
||||
class _Vec {
|
||||
_Vec(this.x, this.y);
|
||||
double x, y;
|
||||
}
|
||||
|
||||
class ForceLayout {
|
||||
/// Lay out [nodeIds] connected by [edges] (pairs of node ids) in a
|
||||
/// [width]×[height] area over [iterations] steps. Edges referencing an unknown
|
||||
/// node are ignored. Returns each node's settled position, clamped to the area.
|
||||
static Map<String, GraphPoint> compute(List<String> nodeIds, List<(String, String)> edges, {double width = 800, double height = 600, int iterations = 200}) {
|
||||
final n = nodeIds.length;
|
||||
if (n == 0) return const {};
|
||||
final cx = width / 2, cy = height / 2;
|
||||
if (n == 1) return {nodeIds.first: (x: cx, y: cy)};
|
||||
|
||||
// Deterministic circular seed.
|
||||
final pos = <String, _Vec>{};
|
||||
for (var i = 0; i < n; i++) {
|
||||
final a = 2 * math.pi * i / n;
|
||||
pos[nodeIds[i]] = _Vec(cx + math.cos(a) * width / 4, cy + math.sin(a) * height / 4);
|
||||
}
|
||||
|
||||
final valid = edges.where((e) => pos.containsKey(e.$1) && pos.containsKey(e.$2) && e.$1 != e.$2).toList();
|
||||
final k = math.sqrt(width * height / n); // ideal edge length
|
||||
var temp = width / 10;
|
||||
|
||||
for (var iter = 0; iter < iterations; iter++) {
|
||||
final disp = {for (final id in nodeIds) id: _Vec(0, 0)};
|
||||
|
||||
// Repulsion between every pair.
|
||||
for (var i = 0; i < n; i++) {
|
||||
for (var j = i + 1; j < n; j++) {
|
||||
final a = pos[nodeIds[i]]!, b = pos[nodeIds[j]]!;
|
||||
var dx = a.x - b.x, dy = a.y - b.y;
|
||||
var dist = math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 0.01) {
|
||||
dx = 0.01 * (i.isEven ? 1 : -1);
|
||||
dy = 0.01;
|
||||
dist = 0.01;
|
||||
}
|
||||
final force = k * k / dist;
|
||||
final ux = dx / dist, uy = dy / dist;
|
||||
disp[nodeIds[i]]!
|
||||
..x += ux * force
|
||||
..y += uy * force;
|
||||
disp[nodeIds[j]]!
|
||||
..x -= ux * force
|
||||
..y -= uy * force;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges.
|
||||
for (final e in valid) {
|
||||
final a = pos[e.$1]!, b = pos[e.$2]!;
|
||||
final dx = a.x - b.x, dy = a.y - b.y;
|
||||
final dist = math.max(0.01, math.sqrt(dx * dx + dy * dy));
|
||||
final force = dist * dist / k;
|
||||
final ux = dx / dist, uy = dy / dist;
|
||||
disp[e.$1]!
|
||||
..x -= ux * force
|
||||
..y -= uy * force;
|
||||
disp[e.$2]!
|
||||
..x += ux * force
|
||||
..y += uy * force;
|
||||
}
|
||||
|
||||
// Apply, capped by the temperature, clamped to the area.
|
||||
for (final id in nodeIds) {
|
||||
final d = disp[id]!;
|
||||
final len = math.max(0.01, math.sqrt(d.x * d.x + d.y * d.y));
|
||||
final step = math.min(len, temp);
|
||||
final p = pos[id]!;
|
||||
p.x = (p.x + d.x / len * step).clamp(0.0, width);
|
||||
p.y = (p.y + d.y / len * step).clamp(0.0, height);
|
||||
}
|
||||
temp *= 0.95; // cool
|
||||
}
|
||||
|
||||
return {for (final id in nodeIds) id: (x: pos[id]!.x, y: pos[id]!.y)};
|
||||
}
|
||||
}
|
||||
@@ -156,8 +156,15 @@ class CommandSchema {
|
||||
final out = <String, Object?>{};
|
||||
final pos = raw['positional'];
|
||||
if (pos is List) {
|
||||
for (var i = 0; i < pos.length && i < positional.length; i++) {
|
||||
out[positional[i]] = pos[i];
|
||||
for (var i = 0; i < positional.length; i++) {
|
||||
final name = positional[i];
|
||||
// A trailing stringList positional is variadic: it absorbs ALL the
|
||||
// remaining tokens (e.g. `icon show gear folder gauge`), not just one.
|
||||
if (i == positional.length - 1 && args[name]?.type == ArgType.stringList) {
|
||||
out[name] = i < pos.length ? pos.sublist(i) : const <Object?>[];
|
||||
break;
|
||||
}
|
||||
if (i < pos.length) out[name] = pos[i];
|
||||
}
|
||||
}
|
||||
final flags = raw['flags'];
|
||||
|
||||
@@ -70,7 +70,15 @@ class _McpSession {
|
||||
/// HTTP + SSE MCP server. Lifecycle mirrors [IpcServer]: `start()`
|
||||
/// binds + writes the discovery file; `stop()` unbinds + removes it.
|
||||
class McpServer {
|
||||
McpServer({required this.workspaceRoot, required this.log, this.dispatcher, this.discoveryDirOverride, this.bindHost = '127.0.0.1', this.bindPort = 0});
|
||||
McpServer({
|
||||
required this.workspaceRoot,
|
||||
required this.log,
|
||||
this.dispatcher,
|
||||
this.discoveryDirOverride,
|
||||
this.boundConfigDir,
|
||||
this.bindHost = '127.0.0.1',
|
||||
this.bindPort = 0,
|
||||
});
|
||||
|
||||
/// Workspace root reported in the discovery file. Helps Claude
|
||||
/// Code show "which clide is this" when multiple are running.
|
||||
@@ -86,6 +94,14 @@ class McpServer {
|
||||
/// passes null; tests inject a tempdir.
|
||||
final String? discoveryDirOverride;
|
||||
|
||||
/// Returns the Claude config dir bound to this workspace (T-479/T-480), or
|
||||
/// null when unbound. When non-null, a copy of the discovery lock is also
|
||||
/// written into that dir's `ide/`. A `claude` started with a custom
|
||||
/// `CLAUDE_CONFIG_DIR` looks for its `/ide` lock under that dir, not under
|
||||
/// `~/.claude/ide`, so without this its IDE bridge can't reach clide. Lazy:
|
||||
/// main.dart passes a closure resolved against the (post-boot) AccountRegistry.
|
||||
final String? Function()? boundConfigDir;
|
||||
|
||||
/// Bind host. localhost-only by default per D-73 (no remote
|
||||
/// access; the threat model matches D-71's `0600`).
|
||||
final String bindHost;
|
||||
@@ -94,7 +110,11 @@ class McpServer {
|
||||
final int bindPort;
|
||||
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
|
||||
/// Every discovery-lock path this process has written — the default `ide/`
|
||||
/// dir plus any bound-account `ide/` dirs. Reconciled by [syncDiscoveryLocks];
|
||||
/// all are removed on [stop] so no orphan locks survive (T-479).
|
||||
final Set<String> _lockFiles = {};
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
@@ -102,7 +122,13 @@ class McpServer {
|
||||
|
||||
bool get isRunning => _http != null;
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
String get _defaultIdeDir => discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
|
||||
String get _defaultLockPath => '$_defaultIdeDir/$pid.lock';
|
||||
|
||||
/// The lock in the default `~/.claude/ide` dir (the one Claude finds without
|
||||
/// `CLAUDE_CONFIG_DIR`). Null until [start] writes it.
|
||||
String? get lockFilePath => _lockFiles.contains(_defaultLockPath) ? _defaultLockPath : null;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
@@ -114,7 +140,7 @@ class McpServer {
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
await syncDiscoveryLocks();
|
||||
server.listen(
|
||||
_route,
|
||||
onError: (Object e, StackTrace st) {
|
||||
@@ -134,16 +160,10 @@ class McpServer {
|
||||
}
|
||||
_sessions.clear();
|
||||
await s.close(force: true);
|
||||
final lock = _lockFile;
|
||||
_lockFile = null;
|
||||
if (lock != null) {
|
||||
try {
|
||||
final f = File(lock);
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (e) {
|
||||
log.warn('mcp', 'failed to unlink lock $lock: $e');
|
||||
}
|
||||
for (final lock in _lockFiles) {
|
||||
_deleteLock(lock);
|
||||
}
|
||||
_lockFiles.clear();
|
||||
}
|
||||
|
||||
// -- routing --------------------------------------------------------------
|
||||
@@ -340,13 +360,43 @@ class McpServer {
|
||||
|
||||
// -- discovery file -------------------------------------------------------
|
||||
|
||||
Future<String> _writeDiscoveryFile() async {
|
||||
final dir = discoveryDirOverride ?? '${Platform.environment['HOME'] ?? '/tmp'}/.claude/ide';
|
||||
final dirHandle = Directory(dir);
|
||||
/// The `ide/` dirs a discovery lock should currently live in: the default
|
||||
/// `~/.claude/ide` always, plus the bound account's `<dir>/ide` when this
|
||||
/// workspace is bound to a non-default account (T-479). Deduped, order-stable.
|
||||
List<String> _activeIdeDirs() {
|
||||
final dirs = <String>[_defaultIdeDir];
|
||||
final bound = boundConfigDir?.call();
|
||||
if (bound != null && bound.isNotEmpty) {
|
||||
final accountIde = '$bound/ide';
|
||||
if (!dirs.contains(accountIde)) dirs.add(accountIde);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/// Reconcile the on-disk discovery locks with the currently-active `ide/`
|
||||
/// dirs (T-479): write a lock into each active dir, and remove any this
|
||||
/// process wrote into a dir that is no longer active. Called on [start] and
|
||||
/// whenever a per-repo account binding changes. No-op while not running.
|
||||
Future<void> syncDiscoveryLocks() async {
|
||||
if (!isRunning) return;
|
||||
final want = {for (final d in _activeIdeDirs()) '$d/$pid.lock'};
|
||||
for (final path in _lockFiles.difference(want).toList()) {
|
||||
_deleteLock(path);
|
||||
_lockFiles.remove(path);
|
||||
}
|
||||
for (final path in want.difference(_lockFiles).toList()) {
|
||||
await _writeLockAt(path);
|
||||
_lockFiles.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write (or overwrite) the discovery lock at [path] — same content in every
|
||||
/// dir — creating the `ide/` parent and 0600-scoping the file (T-362).
|
||||
Future<void> _writeLockAt(String path) async {
|
||||
final dirHandle = File(path).parent;
|
||||
if (!dirHandle.existsSync()) {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
@@ -364,7 +414,15 @@ class McpServer {
|
||||
// ~/.claude which the home-dir perms usually already protect. But say so.
|
||||
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
void _deleteLock(String path) {
|
||||
try {
|
||||
final f = File(path);
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (e) {
|
||||
log.warn('mcp', 'failed to unlink lock $path: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
|
||||
@@ -82,10 +82,14 @@ class IpcServer {
|
||||
/// listening on the path the bind throws — the caller is the
|
||||
/// stale-vs-live arbiter (per D-72 there's one server per
|
||||
/// workspace; a colliding live process means a real conflict).
|
||||
/// Orphaned sockets from crashed instances of OTHER workspaces are
|
||||
/// also swept from the runtime dir on startup (T-247), so the dir
|
||||
/// doesn't accumulate dead nodes.
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final path = workspaceSocketPath(workspaceRoot);
|
||||
await _prepareParentDir(path);
|
||||
await _sweepStaleSockets(path);
|
||||
await _unlinkStale(path);
|
||||
final socket = await ServerSocket.bind(InternetAddress(path, type: InternetAddressType.unix), 0);
|
||||
try {
|
||||
@@ -249,6 +253,38 @@ class IpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep the runtime socket dir for orphaned `*.sock` nodes left by crashed
|
||||
/// instances of OTHER workspaces (T-247): probe each, unlink only the dead
|
||||
/// ones. A live instance (something answers) or an unresponsive node (could
|
||||
/// be a hung instance) is left untouched; the current workspace's own path is
|
||||
/// handled by [_unlinkStale]. Best-effort — a sweep failure never blocks our
|
||||
/// own startup.
|
||||
Future<void> _sweepStaleSockets(String selfPath) async {
|
||||
try {
|
||||
final dir = Directory(File(selfPath).parent.path);
|
||||
if (!dir.existsSync()) return;
|
||||
for (final entry in dir.listSync()) {
|
||||
if (entry is! File || !entry.path.endsWith('.sock') || entry.path == selfPath) continue;
|
||||
try {
|
||||
final probe = await Socket.connect(InternetAddress(entry.path, type: InternetAddressType.unix), 0).timeout(const Duration(milliseconds: 200));
|
||||
await probe.close(); // live instance — leave it alone
|
||||
} on SocketException {
|
||||
// No listener — an orphan from a crashed instance. Unlink it.
|
||||
try {
|
||||
entry.deleteSync();
|
||||
log.info('ipc', 'swept orphaned socket ${entry.path}');
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'failed to sweep ${entry.path}: $e');
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Exists but unresponsive — possibly a hung instance; don't clobber.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'socket sweep failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unlinkStale(String path) async {
|
||||
final f = File(path);
|
||||
if (!f.existsSync()) return;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/// SVG / CSS colour parsing for the renderer (T-320 / D-103).
|
||||
///
|
||||
/// Converts an SVG colour string into a packed `0xAARRGGBB` int the painter can
|
||||
/// hand to a `dart:ui` Color. Handles `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`,
|
||||
/// `rgb()/rgba()` (integer or percentage channels), the common named colours,
|
||||
/// and `none`/`transparent` (→ fully transparent, so the painter simply skips
|
||||
/// it). Returns `null` for anything unrecognised so the caller can fall back to
|
||||
/// the inherited / default paint. Never throws.
|
||||
///
|
||||
/// A colour is CONTENT, not a clide theme token (D-103 / D-7): an SVG fill is
|
||||
/// whatever the document says, independent of clide's palette.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
/// Parse an SVG colour to packed ARGB (`0xAARRGGBB`); `null` if unrecognised.
|
||||
/// `none` / `transparent` → `0x00000000`.
|
||||
int? parseSvgColor(String raw) {
|
||||
final s = raw.trim().toLowerCase();
|
||||
if (s.isEmpty) return null;
|
||||
if (s == 'none' || s == 'transparent') return 0x00000000;
|
||||
if (s.startsWith('#')) return _hex(s.substring(1));
|
||||
if (s.startsWith('rgb')) return _rgb(s);
|
||||
return _named[s];
|
||||
}
|
||||
|
||||
int? _hex(String h) {
|
||||
String dbl(String c) => '$c$c';
|
||||
String rr, gg, bb, aa;
|
||||
switch (h.length) {
|
||||
case 3:
|
||||
rr = dbl(h[0]);
|
||||
gg = dbl(h[1]);
|
||||
bb = dbl(h[2]);
|
||||
aa = 'ff';
|
||||
case 4:
|
||||
rr = dbl(h[0]);
|
||||
gg = dbl(h[1]);
|
||||
bb = dbl(h[2]);
|
||||
aa = dbl(h[3]);
|
||||
case 6:
|
||||
rr = h.substring(0, 2);
|
||||
gg = h.substring(2, 4);
|
||||
bb = h.substring(4, 6);
|
||||
aa = 'ff';
|
||||
case 8:
|
||||
rr = h.substring(0, 2);
|
||||
gg = h.substring(2, 4);
|
||||
bb = h.substring(4, 6);
|
||||
aa = h.substring(6, 8);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
final r = int.tryParse(rr, radix: 16);
|
||||
final g = int.tryParse(gg, radix: 16);
|
||||
final b = int.tryParse(bb, radix: 16);
|
||||
final a = int.tryParse(aa, radix: 16);
|
||||
if (r == null || g == null || b == null || a == null) return null;
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
int? _rgb(String s) {
|
||||
final open = s.indexOf('('), close = s.indexOf(')');
|
||||
if (open < 0 || close < open) return null;
|
||||
final parts = s.substring(open + 1, close).split(',').map((p) => p.trim()).toList();
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
int chan(String p) {
|
||||
if (p.endsWith('%')) {
|
||||
final pct = double.tryParse(p.substring(0, p.length - 1)) ?? 0;
|
||||
return (pct / 100 * 255).round().clamp(0, 255);
|
||||
}
|
||||
return (double.tryParse(p) ?? 0).round().clamp(0, 255);
|
||||
}
|
||||
|
||||
final r = chan(parts[0]), g = chan(parts[1]), b = chan(parts[2]);
|
||||
var a = 255;
|
||||
if (parts.length >= 4) {
|
||||
final af = double.tryParse(parts[3]);
|
||||
if (af != null) a = (af * 255).round().clamp(0, 255);
|
||||
}
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
/// Common named colours (the CSS basics plus a few greys d2/graphviz emit).
|
||||
/// Extended names can be added as needed — d2 uses hex, so this is mostly for
|
||||
/// hand-authored SVG.
|
||||
const Map<String, int> _named = {
|
||||
'black': 0xFF000000,
|
||||
'white': 0xFFFFFFFF,
|
||||
'red': 0xFFFF0000,
|
||||
'lime': 0xFF00FF00,
|
||||
'green': 0xFF008000,
|
||||
'blue': 0xFF0000FF,
|
||||
'yellow': 0xFFFFFF00,
|
||||
'cyan': 0xFF00FFFF,
|
||||
'aqua': 0xFF00FFFF,
|
||||
'magenta': 0xFFFF00FF,
|
||||
'fuchsia': 0xFFFF00FF,
|
||||
'silver': 0xFFC0C0C0,
|
||||
'gray': 0xFF808080,
|
||||
'grey': 0xFF808080,
|
||||
'maroon': 0xFF800000,
|
||||
'olive': 0xFF808000,
|
||||
'teal': 0xFF008080,
|
||||
'navy': 0xFF000080,
|
||||
'purple': 0xFF800080,
|
||||
'orange': 0xFFFFA500,
|
||||
'pink': 0xFFFFC0CB,
|
||||
'brown': 0xFFA52A2A,
|
||||
'gold': 0xFFFFD700,
|
||||
'lightgray': 0xFFD3D3D3,
|
||||
'lightgrey': 0xFFD3D3D3,
|
||||
'darkgray': 0xFFA9A9A9,
|
||||
'darkgrey': 0xFFA9A9A9,
|
||||
'whitesmoke': 0xFFF5F5F5,
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
/// Builds the typed [SvgDocument] scene from raw SVG text (T-320 / D-103).
|
||||
///
|
||||
/// Pipeline: [parseXml] → [inlineStyles] (flatten classes to inline attrs) →
|
||||
/// walk the tree into typed [SvgNode]s, resolving each element's geometry,
|
||||
/// `transform` ([Affine]), and presentation [SvgStyle] with inheritance applied
|
||||
/// down the tree. Colours resolve to packed ARGB ([parseSvgColor]); paths to
|
||||
/// [SvgPathSeg]s ([parseSvgPath]).
|
||||
///
|
||||
/// Tolerant: a non-`<svg>` root yields [SvgDocument.empty]; unknown elements
|
||||
/// (and `defs`/`marker`, deferred this slice) are skipped. Never throws.
|
||||
///
|
||||
/// Flutter-free: pure Dart, runs under `dart test`.
|
||||
library;
|
||||
|
||||
import 'svg_color.dart';
|
||||
import 'svg_node.dart';
|
||||
import 'svg_path.dart';
|
||||
import 'svg_style.dart';
|
||||
import 'svg_transform.dart';
|
||||
import 'svg_xml.dart';
|
||||
|
||||
/// Parse raw SVG into the typed scene model.
|
||||
SvgDocument buildSvgDocument(String src) {
|
||||
final root = parseXml(src);
|
||||
if (root == null || root.name != 'svg') return SvgDocument.empty;
|
||||
inlineStyles(root);
|
||||
|
||||
final markers = <String, SvgMarker>{};
|
||||
_collectMarkers(root, markers);
|
||||
|
||||
final annotations = <SvgAnnotation>[];
|
||||
final style = _resolveStyle(root.attrs, SvgStyle.initial);
|
||||
final rootTf = _transform(root.attrs['transform']);
|
||||
final children = _children(root, style, rootTf ?? Affine.identity, annotations);
|
||||
return SvgDocument(
|
||||
width: _lenN(root.attrs['width']),
|
||||
height: _lenN(root.attrs['height']),
|
||||
viewBox: _viewBox(root.attrs['viewBox']),
|
||||
root: SvgGroup(style, rootTf, children),
|
||||
markers: markers,
|
||||
annotations: annotations,
|
||||
);
|
||||
}
|
||||
|
||||
void _collectMarkers(XmlElement el, Map<String, SvgMarker> into) {
|
||||
if (el.name == 'marker') {
|
||||
final id = el.attrs['id'];
|
||||
if (id != null && id.isNotEmpty) into[id] = _marker(el);
|
||||
}
|
||||
for (final c in el.children) {
|
||||
if (c is XmlElement) _collectMarkers(c, into);
|
||||
}
|
||||
}
|
||||
|
||||
SvgMarker _marker(XmlElement el) {
|
||||
final orient = el.attrs['orient'];
|
||||
final auto = orient == 'auto' || orient == 'auto-start-reverse';
|
||||
return SvgMarker(
|
||||
refX: _num(el.attrs['refX']),
|
||||
refY: _num(el.attrs['refY']),
|
||||
orientAuto: auto,
|
||||
orientAngle: (orient != null && !auto) ? (_stripNum(orient) ?? 0) : 0,
|
||||
strokeScaled: el.attrs['markerUnits'] != 'userSpaceOnUse', // default = strokeWidth
|
||||
viewBox: _viewBox(el.attrs['viewBox']),
|
||||
children: _children(el, SvgStyle.initial, Affine.identity, []), // markers don't carry annotations
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract the id from a `marker-*` value like `url(#id)`.
|
||||
String? _markerRef(String? v) {
|
||||
if (v == null) return null;
|
||||
final m = RegExp(r'url\(\s*#([^)\s]+)\s*\)').firstMatch(v);
|
||||
return m?.group(1) ?? (v.startsWith('#') ? v.substring(1) : null);
|
||||
}
|
||||
|
||||
List<SvgNode> _children(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
|
||||
final out = <SvgNode>[];
|
||||
for (final c in el.children) {
|
||||
if (c is XmlElement) {
|
||||
final n = _node(c, inherited, accumulated, annotations);
|
||||
if (n != null) out.add(n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
SvgNode? _node(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
|
||||
final style = _resolveStyle(el.attrs, inherited);
|
||||
final tf = _transform(el.attrs['transform']);
|
||||
final acc = tf == null ? accumulated : accumulated.multiply(tf);
|
||||
final node = _build(el, style, tf, acc, annotations);
|
||||
if (node != null) _maybeAnnotate(el, node, acc, annotations);
|
||||
return node;
|
||||
}
|
||||
|
||||
SvgNode? _build(XmlElement el, SvgStyle style, Affine? tf, Affine acc, List<SvgAnnotation> annotations) {
|
||||
final a = el.attrs;
|
||||
switch (el.name) {
|
||||
case 'g':
|
||||
case 'a':
|
||||
case 'svg':
|
||||
return SvgGroup(style, tf, _children(el, style, acc, annotations));
|
||||
case 'rect':
|
||||
final rx = _numN(a['rx']), ry = _numN(a['ry']);
|
||||
return SvgRect(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), rx ?? ry ?? 0, ry ?? rx ?? 0);
|
||||
case 'circle':
|
||||
final r = _num(a['r']);
|
||||
return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), r, r);
|
||||
case 'ellipse':
|
||||
return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), _num(a['rx']), _num(a['ry']));
|
||||
case 'line':
|
||||
return SvgLine(style, tf, _num(a['x1']), _num(a['y1']), _num(a['x2']), _num(a['y2']));
|
||||
case 'polyline':
|
||||
return SvgPolyline(style, tf, _points(a['points']), false);
|
||||
case 'polygon':
|
||||
return SvgPolyline(style, tf, _points(a['points']), true);
|
||||
case 'path':
|
||||
return SvgPath(
|
||||
style,
|
||||
tf,
|
||||
parseSvgPath(a['d'] ?? ''),
|
||||
markerStart: _markerRef(a['marker-start']),
|
||||
markerMid: _markerRef(a['marker-mid']),
|
||||
markerEnd: _markerRef(a['marker-end']),
|
||||
);
|
||||
case 'text':
|
||||
return SvgText(style, tf, _num(a['x']), _num(a['y']), _textOf(el));
|
||||
case 'image':
|
||||
return SvgImage(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), a['href'] ?? a['xlink:href'] ?? '');
|
||||
default:
|
||||
return null; // defs, marker, title, desc, unknown — skipped
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeAnnotate(XmlElement el, SvgNode node, Affine acc, List<SvgAnnotation> annotations) {
|
||||
final label = el.attrs['data-label'];
|
||||
final desc = el.attrs['data-description'];
|
||||
final lightbox = el.attrs.containsKey('data-lightbox');
|
||||
if (label == null && desc == null && !lightbox) return;
|
||||
final box = _localBBox(node);
|
||||
if (box == null) return;
|
||||
final r = _transformedAABB(box, acc);
|
||||
annotations.add(
|
||||
SvgAnnotation(x: r[0], y: r[1], width: r[2], height: r[3], label: label, description: desc, lightbox: lightbox, href: node is SvgImage ? node.href : null),
|
||||
);
|
||||
}
|
||||
|
||||
/// Axis-aligned bounding box of [node] in its own local coordinates. Groups are
|
||||
/// not bounded (anchor leaf shapes); text degenerates to its anchor point.
|
||||
List<double>? _localBBox(SvgNode node) {
|
||||
switch (node) {
|
||||
case SvgRect r:
|
||||
return [r.x, r.y, r.width, r.height];
|
||||
case SvgImage i:
|
||||
return [i.x, i.y, i.width, i.height];
|
||||
case SvgEllipse e:
|
||||
return [e.cx - e.rx, e.cy - e.ry, e.rx * 2, e.ry * 2];
|
||||
case SvgLine l:
|
||||
return _aabbOfPoints([l.x1, l.y1, l.x2, l.y2]);
|
||||
case SvgPolyline p:
|
||||
return _aabbOfPoints(p.points);
|
||||
case SvgPath p:
|
||||
return _aabbOfPoints(_pathPoints(p.segments));
|
||||
case SvgText t:
|
||||
return [t.x, t.y, 0, 0];
|
||||
case SvgGroup _:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<double> _pathPoints(List<SvgPathSeg> segs) {
|
||||
final pts = <double>[];
|
||||
for (final s in segs) {
|
||||
switch (s.op) {
|
||||
case SvgPathOp.moveTo:
|
||||
case SvgPathOp.lineTo:
|
||||
pts.addAll([s.args[0], s.args[1]]);
|
||||
case SvgPathOp.cubicTo:
|
||||
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]]);
|
||||
case SvgPathOp.quadTo:
|
||||
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3]]);
|
||||
case SvgPathOp.arcTo:
|
||||
pts.addAll([s.args[5], s.args[6]]);
|
||||
case SvgPathOp.close:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
List<double>? _aabbOfPoints(List<double> pts) {
|
||||
if (pts.length < 2) return null;
|
||||
var minX = pts[0], minY = pts[1], maxX = pts[0], maxY = pts[1];
|
||||
for (var i = 0; i + 1 < pts.length; i += 2) {
|
||||
minX = pts[i] < minX ? pts[i] : minX;
|
||||
maxX = pts[i] > maxX ? pts[i] : maxX;
|
||||
minY = pts[i + 1] < minY ? pts[i + 1] : minY;
|
||||
maxY = pts[i + 1] > maxY ? pts[i + 1] : maxY;
|
||||
}
|
||||
return [minX, minY, maxX - minX, maxY - minY];
|
||||
}
|
||||
|
||||
/// Transform [box] = `[x, y, w, h]` by [m]; return the AABB `[x, y, w, h]`.
|
||||
List<double> _transformedAABB(List<double> box, Affine m) {
|
||||
final x = box[0], y = box[1], w = box[2], h = box[3];
|
||||
final corners = [m.apply(x, y), m.apply(x + w, y), m.apply(x, y + h), m.apply(x + w, y + h)];
|
||||
var minX = corners[0].$1, minY = corners[0].$2, maxX = corners[0].$1, maxY = corners[0].$2;
|
||||
for (final c in corners) {
|
||||
minX = c.$1 < minX ? c.$1 : minX;
|
||||
maxX = c.$1 > maxX ? c.$1 : maxX;
|
||||
minY = c.$2 < minY ? c.$2 : minY;
|
||||
maxY = c.$2 > maxY ? c.$2 : maxY;
|
||||
}
|
||||
return [minX, minY, maxX - minX, maxY - minY];
|
||||
}
|
||||
|
||||
SvgStyle _resolveStyle(Map<String, String> a, SvgStyle inh) {
|
||||
int? color(String k, int? fb) {
|
||||
final v = a[k];
|
||||
return v == null ? fb : (parseSvgColor(v) ?? fb);
|
||||
}
|
||||
|
||||
double? dbl(String k, double? fb) {
|
||||
final v = a[k];
|
||||
return v == null ? fb : (_stripNum(v) ?? fb);
|
||||
}
|
||||
|
||||
return SvgStyle(
|
||||
fill: color('fill', inh.fill),
|
||||
stroke: color('stroke', inh.stroke),
|
||||
strokeWidth: dbl('stroke-width', inh.strokeWidth),
|
||||
opacity: _stripNum(a['opacity'] ?? '') ?? 1.0, // not inherited
|
||||
fillOpacity: dbl('fill-opacity', inh.fillOpacity),
|
||||
strokeOpacity: dbl('stroke-opacity', inh.strokeOpacity),
|
||||
dashArray: a.containsKey('stroke-dasharray') ? _dash(a['stroke-dasharray']!) : inh.dashArray,
|
||||
lineCap: a.containsKey('stroke-linecap') ? _cap(a['stroke-linecap']!) : inh.lineCap,
|
||||
lineJoin: a.containsKey('stroke-linejoin') ? _join(a['stroke-linejoin']!) : inh.lineJoin,
|
||||
fontFamily: a['font-family'] ?? inh.fontFamily,
|
||||
fontSize: dbl('font-size', inh.fontSize),
|
||||
fontWeight: a.containsKey('font-weight') ? _weight(a['font-weight']!) : inh.fontWeight,
|
||||
textAnchor: a.containsKey('text-anchor') ? _anchor(a['text-anchor']!) : inh.textAnchor,
|
||||
baseline: a.containsKey('dominant-baseline') ? _baseline(a['dominant-baseline']!) : inh.baseline,
|
||||
);
|
||||
}
|
||||
|
||||
SvgLineCap _cap(String v) => switch (v.trim()) {
|
||||
'round' => SvgLineCap.round,
|
||||
'square' => SvgLineCap.square,
|
||||
_ => SvgLineCap.butt,
|
||||
};
|
||||
|
||||
SvgLineJoin _join(String v) => switch (v.trim()) {
|
||||
'round' => SvgLineJoin.round,
|
||||
'bevel' => SvgLineJoin.bevel,
|
||||
_ => SvgLineJoin.miter,
|
||||
};
|
||||
|
||||
SvgTextAnchor _anchor(String v) => switch (v.trim()) {
|
||||
'middle' => SvgTextAnchor.middle,
|
||||
'end' => SvgTextAnchor.end,
|
||||
_ => SvgTextAnchor.start,
|
||||
};
|
||||
|
||||
SvgBaseline _baseline(String v) => switch (v.trim()) {
|
||||
'middle' || 'central' => SvgBaseline.middle,
|
||||
'hanging' || 'text-before-edge' => SvgBaseline.hanging,
|
||||
_ => SvgBaseline.auto,
|
||||
};
|
||||
|
||||
int _weight(String v) => switch (v.trim()) {
|
||||
'bold' => 700,
|
||||
'normal' => 400,
|
||||
_ => int.tryParse(v.trim()) ?? 400,
|
||||
};
|
||||
|
||||
List<double> _dash(String v) {
|
||||
if (v.trim() == 'none') return const [];
|
||||
return v.split(RegExp(r'[\s,]+')).map(_stripNum).whereType<double>().toList();
|
||||
}
|
||||
|
||||
List<double> _points(String? v) {
|
||||
if (v == null) return const [];
|
||||
return v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).whereType<double>().toList();
|
||||
}
|
||||
|
||||
SvgViewBox? _viewBox(String? v) {
|
||||
if (v == null) return null;
|
||||
final n = v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).toList();
|
||||
if (n.length < 4 || n.any((x) => x == null)) return null;
|
||||
return SvgViewBox(n[0]!, n[1]!, n[2]!, n[3]!);
|
||||
}
|
||||
|
||||
Affine? _transform(String? v) {
|
||||
if (v == null || v.isEmpty) return null;
|
||||
final t = parseTransform(v);
|
||||
return t.isIdentity ? null : t;
|
||||
}
|
||||
|
||||
String _textOf(XmlElement el) {
|
||||
final buf = StringBuffer();
|
||||
for (final n in el.descendants()) {
|
||||
if (n is XmlText) buf.write(n.text);
|
||||
}
|
||||
return buf.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
/// Parse a length, ignoring a trailing unit (`12px` → 12); `null` if no number.
|
||||
double? _stripNum(String v) {
|
||||
final m = RegExp(r'[-+]?(?:[0-9]*\.[0-9]+|[0-9]+)(?:[eE][-+]?[0-9]+)?').firstMatch(v.trim());
|
||||
return m == null ? null : double.tryParse(m.group(0)!);
|
||||
}
|
||||
|
||||
double _num(String? v) => v == null ? 0 : (_stripNum(v) ?? 0);
|
||||
|
||||
double? _numN(String? v) => v == null ? null : _stripNum(v);
|
||||
|
||||
double? _lenN(String? v) => _numN(v);
|
||||
@@ -0,0 +1,185 @@
|
||||
/// The typed SVG scene model the painter draws (T-320 / D-103).
|
||||
///
|
||||
/// The document builder ([buildSvgDocument]) lowers a normalized XML tree into
|
||||
/// this model: every node carries a resolved [SvgStyle] (inheritance already
|
||||
/// flattened), an optional [Affine] transform, and typed geometry. Colours are
|
||||
/// packed ARGB ints, not `dart:ui` Colors, so the model stays Flutter-free and
|
||||
/// `dart test`-able; the painter converts.
|
||||
///
|
||||
/// `null` style fields mean "unspecified" — the painter applies the SVG default
|
||||
/// (fill black, stroke none, stroke-width 1).
|
||||
library;
|
||||
|
||||
import 'svg_path.dart';
|
||||
import 'svg_transform.dart';
|
||||
|
||||
enum SvgLineCap { butt, round, square }
|
||||
|
||||
enum SvgLineJoin { miter, round, bevel }
|
||||
|
||||
enum SvgTextAnchor { start, middle, end }
|
||||
|
||||
enum SvgBaseline { auto, middle, hanging }
|
||||
|
||||
/// Resolved presentation style for a node, with inheritance already applied.
|
||||
class SvgStyle {
|
||||
const SvgStyle({
|
||||
this.fill,
|
||||
this.stroke,
|
||||
this.strokeWidth,
|
||||
this.opacity = 1.0,
|
||||
this.fillOpacity,
|
||||
this.strokeOpacity,
|
||||
this.dashArray,
|
||||
this.lineCap,
|
||||
this.lineJoin,
|
||||
this.fontFamily,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.textAnchor,
|
||||
this.baseline,
|
||||
});
|
||||
|
||||
/// The root inheritance context: nothing specified, full opacity.
|
||||
static const initial = SvgStyle();
|
||||
|
||||
final int? fill; // ARGB; 0x00000000 = explicit none
|
||||
final int? stroke; // ARGB
|
||||
final double? strokeWidth;
|
||||
final double opacity; // element/group opacity — NOT inherited
|
||||
final double? fillOpacity;
|
||||
final double? strokeOpacity;
|
||||
final List<double>? dashArray;
|
||||
final SvgLineCap? lineCap;
|
||||
final SvgLineJoin? lineJoin;
|
||||
final String? fontFamily;
|
||||
final double? fontSize;
|
||||
final int? fontWeight; // 400, 700, …
|
||||
final SvgTextAnchor? textAnchor;
|
||||
final SvgBaseline? baseline;
|
||||
}
|
||||
|
||||
/// A node in the typed scene — group or leaf. [style] is fully resolved;
|
||||
/// [transform] is `null` when identity.
|
||||
sealed class SvgNode {
|
||||
const SvgNode(this.style, this.transform);
|
||||
final SvgStyle style;
|
||||
final Affine? transform;
|
||||
}
|
||||
|
||||
class SvgGroup extends SvgNode {
|
||||
const SvgGroup(super.style, super.transform, this.children);
|
||||
final List<SvgNode> children;
|
||||
}
|
||||
|
||||
class SvgRect extends SvgNode {
|
||||
const SvgRect(super.style, super.transform, this.x, this.y, this.width, this.height, this.rx, this.ry);
|
||||
final double x, y, width, height, rx, ry;
|
||||
}
|
||||
|
||||
/// Circle is an ellipse with `rx == ry`.
|
||||
class SvgEllipse extends SvgNode {
|
||||
const SvgEllipse(super.style, super.transform, this.cx, this.cy, this.rx, this.ry);
|
||||
final double cx, cy, rx, ry;
|
||||
}
|
||||
|
||||
class SvgLine extends SvgNode {
|
||||
const SvgLine(super.style, super.transform, this.x1, this.y1, this.x2, this.y2);
|
||||
final double x1, y1, x2, y2;
|
||||
}
|
||||
|
||||
/// Polyline (open) or polygon (`closed == true`). [points] is `[x0,y0,x1,y1,…]`.
|
||||
class SvgPolyline extends SvgNode {
|
||||
const SvgPolyline(super.style, super.transform, this.points, this.closed);
|
||||
final List<double> points;
|
||||
final bool closed;
|
||||
}
|
||||
|
||||
class SvgPath extends SvgNode {
|
||||
const SvgPath(super.style, super.transform, this.segments, {this.markerStart, this.markerMid, this.markerEnd});
|
||||
final List<SvgPathSeg> segments;
|
||||
|
||||
/// Ids (sans `url(#…)`) of `marker-start`/`mid`/`end` definitions, if any.
|
||||
final String? markerStart, markerMid, markerEnd;
|
||||
}
|
||||
|
||||
/// A `<marker>` definition (e.g. an arrowhead), referenced by `marker-*`.
|
||||
class SvgMarker {
|
||||
const SvgMarker({
|
||||
required this.refX,
|
||||
required this.refY,
|
||||
required this.orientAuto,
|
||||
required this.orientAngle,
|
||||
required this.strokeScaled,
|
||||
required this.children,
|
||||
this.viewBox,
|
||||
});
|
||||
|
||||
final double refX, refY;
|
||||
final bool orientAuto; // orient="auto"
|
||||
final double orientAngle; // fixed angle (degrees) when not auto
|
||||
final bool strokeScaled; // markerUnits=strokeWidth (default) vs userSpaceOnUse
|
||||
final SvgViewBox? viewBox;
|
||||
final List<SvgNode> children;
|
||||
}
|
||||
|
||||
class SvgText extends SvgNode {
|
||||
const SvgText(super.style, super.transform, this.x, this.y, this.text);
|
||||
final double x, y;
|
||||
final String text;
|
||||
}
|
||||
|
||||
class SvgImage extends SvgNode {
|
||||
const SvgImage(super.style, super.transform, this.x, this.y, this.width, this.height, this.href);
|
||||
final double x, y, width, height;
|
||||
final String href;
|
||||
}
|
||||
|
||||
/// `viewBox="minX minY width height"`.
|
||||
class SvgViewBox {
|
||||
const SvgViewBox(this.minX, this.minY, this.width, this.height);
|
||||
final double minX, minY, width, height;
|
||||
}
|
||||
|
||||
/// A per-object overlay annotation (T-318, D-103): a caption and/or lightbox
|
||||
/// affordance anchored to an SVG element carrying `data-label` /
|
||||
/// `data-description` / `data-lightbox`. The rect is the element's axis-aligned
|
||||
/// bounding box in viewBox (user-space) coordinates with its transform applied;
|
||||
/// the Flutter overlay maps it through the same viewBox→size fit the painter
|
||||
/// uses, so captions sit under the right spot.
|
||||
class SvgAnnotation {
|
||||
const SvgAnnotation({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.label,
|
||||
this.description,
|
||||
this.lightbox = false,
|
||||
this.href,
|
||||
});
|
||||
|
||||
final double x, y, width, height;
|
||||
final String? label, description;
|
||||
final bool lightbox;
|
||||
final String? href; // image href, for a lightbox target
|
||||
}
|
||||
|
||||
/// A parsed SVG document: optional intrinsic [width]/[height] (px), optional
|
||||
/// [viewBox], the [root] group, marker defs, and per-object overlay
|
||||
/// [annotations].
|
||||
class SvgDocument {
|
||||
const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}, this.annotations = const []});
|
||||
|
||||
static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, []));
|
||||
|
||||
final double? width, height;
|
||||
final SvgViewBox? viewBox;
|
||||
final SvgGroup root;
|
||||
|
||||
/// `<marker>` definitions by id, referenced by path `marker-*`.
|
||||
final Map<String, SvgMarker> markers;
|
||||
|
||||
/// Overlay captions/lightbox anchors extracted from `data-*` attributes.
|
||||
final List<SvgAnnotation> annotations;
|
||||
}
|
||||