9 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 c1b78d2845 release v2.3.3
Patch release: the daemon boots its pql/git/files workspace at the last
opened project instead of the launch directory (HOME) on a desktop
launch, so the ticket/decision sidebars load on first open instead of
erroring against a stale ~/.pql/pql.db (T-352). Also raises the toolchain
floor to Flutter 3.35 / Dart 3.9 and refreshes dependencies after a clean
CVE audit (T-353).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:35:08 +02:00
jpmschweitzerandClaude Opus 4.8 fc4021e98b main: boot the daemon at the last project, not HOME (T-352)
Confirmed root cause of the sidebar failure: a desktop launch starts in
HOME, which isn't a git repo, so resolveWorkspaceRoot returns HOME and
the daemon's pql/git/files all target HOME. pql then finds a stale
~/.pql/pql.db (left from earlier HOME-workdir runs) and errors
"pql.db is from an earlier schema" — exactly what the sidebars showed.
A manual refresh worked only because by then the workspace had swapped
to the repo. (Verified directly: `cd $HOME && pql ticket list` reproduces
the schema error against ~/.pql/pql.db.)

Settings confirmed the user is in the picker path (no startupSticky on
any recent), so nothing auto-opens the repo at boot and the daemon sits
on HOME until the project is opened.

Fix: resolve the startup workspace before boot — prefer the launch CWD
when it's a git repo, else fall back to app.lastProject (the repo). The
daemon now points at the real workspace from its first request, so the
sidebars load immediately without depending on swap/refetch timing. The
ProjectOpened refetch and swap serialization from 2.3.x stay in place for
genuine mid-session project switches.

Pure resolveStartupWorkspace() helper is unit-tested; app boot green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:32:18 +02:00
jpmschweitzerandClaude Opus 4.8 46f84728d2 deps: bump markdown 7.2.2 -> 7.3.1, unblocked by the Dart 3.9 floor (T-353)
Now that the SDK floor is Dart 3.9 (markdown 7.3.1 requires ^3.9.0), take
the bump. 7.3.0/7.3.1 are bug-fixes + perf with no breaking AST/Node
changes (we walk the parsed tree in our own renderer); only API-level
deprecations (LinkContext, BlockParser.standardBlockSyntaxes) we don't
use. licenses.yaml updated in the same commit; markdown goldens green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:12:03 +02:00
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00
jpmschweitzerandClaude Opus 4.8 bcea5f15b7 ci: run osv-scanner in CI, not push-check (T-353)
Keep the security gate out of the local pre-push path so dev machines
don't need osv-scanner installed. The `make security` target and
ci/osv_scan.sh stay for the CI PR-merge pipeline (and on-demand local
runs); only the push-check prerequisite is removed, with a NOTE in the
Makefile explaining where it runs instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:30 +02:00
jpmschweitzerandClaude Opus 4.8 34ce01cbf1 ci: add osv-scanner supply-chain gate to push-check (T-353)
`make push-check` now runs a `security` step (ci/osv_scan.sh) that scans
pubspec.lock with osv-scanner and fails the push if any resolved
dependency has a known advisory. This is a hard, fail-closed gate on top
of `dart pub get`'s passive (non-failing) advisory print.

Replaces the old manual-review `security` no-op target. Slots in among
the instant fail-fast gates, before the coverage suite. Resolves the
osv-scanner binary from PATH, falling back to a brew prefix so the gate
works under the pre-push hook's leaner PATH; if absent it fails with an
install hint (brew install osv-scanner). Native deps (dugite,
tree-sitter, wasmtime) are vendored by SHA and reviewed separately on
bump (D-42), so they're out of scope for the lockfile scan.

Verified clean against the current lockfile (80 packages, no issues).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:06:22 +02:00
jpmschweitzerandClaude Opus 4.8 1238802f54 deps: CVE audit + refresh safe pins, document held ones (T-353)
Reviewed every direct and transitive dependency against the GitHub
Advisory Database / OSV (Pub ecosystem). No advisory affects any
dependency at its current pin or upgrade target — the "N packages have
newer versions" noise is freshness, not security. (Consistent with
`dart pub get` printing no advisory warnings.)

Bumped the safe pins + their licenses.yaml entries in the same commit:
- ffi 2.1.3 -> 2.2.0
- jovial_svg 1.1.26 -> 1.1.30 (pulls jovial_misc 0.10.0 + xml 7.0.1)
- mocktail 1.0.4 -> 1.0.5

Held, with the reason recorded inline in pubspec.yaml:
- markdown 7.2.2: 7.3.1 requires Dart ^3.9.0 — defer to an SDK-floor bump
- alchemist 0.12.1: 0.13.0 disabled text anti-aliasing -> golden churn
- test 1.31.0: flutter_test SDK-locks the resolvable ceiling

make test green (incl. SVG/xml goldens — the xml 6->7 major didn't churn
rendering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:58:54 +02:00
jpmschweitzerandClaude Opus 4.8 4fa4ce1bac release v2.3.2
Patch release: the real fix for the ticket/decision sidebars failing on
first load — IPC-server swaps are now serialized so the repo workspace
bind always wins over the boot launch-CWD bind (T-352). Supersedes the
partial 2.3.1 re-fetch-on-open, which is kept for mid-session switches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:45:17 +02:00
jpmschweitzerandClaude Opus 4.8 7d951a247f main: serialize IPC-server swaps so the repo bind wins (T-352)
The 2.3.1 fix (re-fetch the pql sidebars on ProjectOpened) only helped
the picker-first path, where the project opens after the window is up.
With sticky-startup the project opens during boot, before the panes
mount and subscribe, so they never received the event — the sidebars
stayed broken.

Root cause is a race in the IPC-server lifecycle. The boot factory fires
swapIpcServer(launchCwd) with unawaited(); the project-open flow then
fires swapIpcServer(repo). Each swap stops the live server, binds a new
one, and reconnects the daemon client. Unserialized, the two interleave
and the late-finishing boot swap can clobber the repo bind, reconnecting
the client to the launch-CWD (HOME) socket. The daemon's PqlClient (and
git/files) then run against the wrong workspace, so the first
pql.tickets.list hits a stale/global pql.db and errors
("ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then things had settled.

Chain every swap on a serialization Future so they apply in call order;
the repo swap is issued last and therefore wins. Kept the pane re-fetch
from 2.3.1 — it still covers genuine mid-session project switches.

Verified app boot is unaffected (test/app_test.dart green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:44:17 +02:00
451 changed files with 7822 additions and 12876 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.44.1"
}
+11
View File
@@ -3514,3 +3514,14 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'description', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -177,3 +177,4 @@ 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+28
View File
@@ -3240,3 +3240,31 @@ This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t c
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+53
View File
@@ -16,6 +16,59 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
## [2.3.3] — 2026-06-11
### Fixed
- **Ticket/decision sidebars load on first open after a desktop launch.** A
desktop launch starts in HOME (not a git repo), and the daemon booted its
pql/git/files workspace there — so pql ran in HOME, hit a stale
`~/.pql/pql.db`, and the sidebars showed "pql … failed" until the project was
reopened (a manual refresh worked once the workspace had swapped to the repo).
The daemon now boots at the last opened project when the launch directory
isn't itself a repo, so pql targets the real workspace from the first request.
(T-352)
### Changed
- **Minimum toolchain raised to honest values.** `pubspec.yaml` now declares
Flutter `>=3.35.0` / Dart `>=3.9.0` (was `3.19.0` / `3.5.0`) — the real
minimums our deps already required (`alchemist` needs Flutter 3.32; Dart 3.9
ships in Flutter 3.35). The exact build toolchain is pinned in `.fvmrc`
(Flutter 3.44.1). Moving to the Dart 3.9 language level reformatted the tree
to the new "tall" style and adopted two new lints (`unnecessary_underscores`,
`use_null_aware_elements`). (T-353)
### Security
- **Dependency audit + refresh.** Reviewed every pinned and transitive
dependency against the GitHub Advisory Database / OSV (Pub ecosystem) — no
advisory affects any current or candidate version. Refreshed the safe pins:
`ffi` 2.1.3→2.2.0, `jovial_svg` 1.1.26→1.1.30 (pulls `jovial_misc` 0.10.0 +
`xml` 7.0.1), `mocktail` 1.0.4→1.0.5, and `markdown` 7.2.2→7.3.1 (unblocked by
the Dart 3.9 floor below). Deliberately held with reasons in `pubspec.yaml`:
`alchemist` (0.13 golden churn), `test` (Flutter-SDK locked). (T-353)
- **Supply-chain gate (`make security`).** A new `security` target runs
`osv-scanner` over `pubspec.lock` and fails if any resolved dependency has a
known advisory — a hard gate on top of `dart pub`'s passive, non-failing
advisory print. Intended for the CI PR-merge pipeline (kept out of
`push-check` so dev machines don't need the scanner installed); run locally
any time with `make security`. (T-353)
## [2.3.2] — 2026-06-11
### Fixed
- **Ticket and decision sidebars reliably load on first open (real fix).** The
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
is up; with sticky-startup the project opens during boot, before the panes
mount, so they never saw the event. The underlying cause was a race: the boot
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
ran concurrently, and the late-finishing boot swap could clobber the repo
bind — leaving the daemon's pql/git/files pointed at the launch directory
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
serialized so the repo bind always wins. (T-352)
## [2.3.1] — 2026-06-10
### Fixed
+6 -2
View File
@@ -309,8 +309,8 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
# -- security -------------------------------------------------------------
.PHONY: security
security: ## Dart advisory review.
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
ci/osv_scan.sh
# -- pre-push gate --------------------------------------------------------
@@ -319,6 +319,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
pql decisions validate
.PHONY: push-check
# NOTE: the `security` (osv-scanner) gate is deliberately NOT in push-check —
# it runs in the CI PR-merge pipeline (where the scanner is provisioned) so we
# don't force every dev machine to install osv-scanner. Run it locally any time
# with `make security`.
push-check: decisions-validate changelog-gate test-coverage coverage-gate test-core ## Pre-push gate (fast — <2 min target). Order is fail-fast: instant gates (decisions, changelog) first, then the coverage suite + gate (the expensive, most-likely-to-fail stage) BEFORE test-core — a coverage miss aborts here instead of after running everything, so a fix doesn't force a full re-run of the rest. test-coverage already runs the a11y suite (test/a11y), so no separate test-a11y pass.
.PHONY: push-check-full
+5 -5
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.3.1"
version: "2.3.3"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
@@ -116,7 +116,7 @@ dependencies:
- name: ffi
kind: dart-package
version: "2.1.3"
version: "2.2.0"
homepage: https://pub.dev/packages/ffi
license: BSD-3-Clause
purpose: >-
@@ -171,7 +171,7 @@ dependencies:
- name: jovial_svg
kind: dart-package
version: "1.1.26"
version: "1.1.30"
homepage: https://pub.dev/packages/jovial_svg
license: BSD-3-Clause
purpose: >-
@@ -182,7 +182,7 @@ dependencies:
- name: markdown
kind: dart-package
version: "7.2.2"
version: "7.3.1"
homepage: https://pub.dev/packages/markdown
license: BSD-3-Clause
purpose: >-
@@ -207,7 +207,7 @@ dependencies:
dev_dependencies:
- name: mocktail
kind: dart-package
version: "1.0.4"
version: "1.0.5"
homepage: https://pub.dev/packages/mocktail
license: MIT
purpose: >-
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Supply-chain gate — fails the push if any resolved dependency in
# pubspec.lock has a known advisory (OSV / GitHub Advisory Database).
#
# Complements `dart pub get`'s passive advisory print (informational,
# non-failing) with a hard, fail-closed gate. Native deps (dugite,
# tree-sitter, wasmtime) are vendored by SHA and not in a lockfile OSV
# reads — they're reviewed separately on bump (D-42, CLAUDE.md supply chain).
set -euo pipefail
cd "$(dirname "$0")/.."
# Resolve osv-scanner: PATH first, then a brew prefix (the git pre-push hook
# may run with a leaner PATH than the dev's interactive shell).
OSV="$(command -v osv-scanner || true)"
if [[ -z "$OSV" ]] && command -v brew >/dev/null 2>&1; then
cand="$(brew --prefix 2>/dev/null)/bin/osv-scanner"
[[ -x "$cand" ]] && OSV="$cand"
fi
if [[ -z "$OSV" ]]; then
echo "==> osv gate: osv-scanner not found on PATH." >&2
echo " Install it: brew install osv-scanner" >&2
echo " (or: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest)" >&2
exit 2
fi
echo "==> osv gate: scanning pubspec.lock for known advisories"
if "$OSV" scan source --lockfile=pubspec.lock; then
echo "==> osv gate OK: no known advisories"
else
echo "==> osv gate FAIL: a dependency has a known advisory (see above)." >&2
echo " Bump the affected package (+ its assets/licenses.yaml entry), or" >&2
echo " document an explicit, justified exception before pushing." >&2
exit 1
fi
+96 -96
View File
@@ -71,112 +71,112 @@ class ClideTheme {
// ─── ThemeData ────────────────────────────────────────────────────────
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: _accent,
onPrimary: Color(0xFF0D1020),
secondary: _synKeyword,
onSecondary: Color(0xFF0D1020),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0D1020),
),
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: _accent,
onPrimary: Color(0xFF0D1020),
secondary: _synKeyword,
onSecondary: Color(0xFF0D1020),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0D1020),
),
textTheme: const TextTheme(
// Josefin Sans Light for display; JetBrains Mono for code/body.
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
),
textTheme: const TextTheme(
// Josefin Sans Light for display; JetBrains Mono for code/body.
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
cardTheme: CardThemeData(
color: _surface,
elevation: 0,
shape: RoundedRectangleBorder(
side: const BorderSide(color: _border),
borderRadius: BorderRadius.circular(6),
),
margin: EdgeInsets.zero,
),
cardTheme: CardThemeData(
color: _surface,
elevation: 0,
shape: RoundedRectangleBorder(
side: const BorderSide(color: _border),
borderRadius: BorderRadius.circular(6),
),
margin: EdgeInsets.zero,
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: _bgSunken,
hintStyle: const TextStyle(color: _textMute),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _accent, width: 1.5),
),
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: _bgSunken,
hintStyle: const TextStyle(color: _textMute),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _accent, width: 1.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF0D1020),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: const Color(0xFF0D1020),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
),
),
// Pill-style chips (matches the Projects row in the dashboard).
chipTheme: ChipThemeData(
backgroundColor: _surface,
side: const BorderSide(color: _borderHi),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
// Pill-style chips (matches the Projects row in the dashboard).
chipTheme: ChipThemeData(
backgroundColor: _surface,
side: const BorderSide(color: _borderHi),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
iconTheme: const IconThemeData(color: _textDim, size: 14),
iconTheme: const IconThemeData(color: _textDim, size: 14),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: _surfaceHi,
border: Border.all(color: _borderHi),
borderRadius: BorderRadius.circular(3),
),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: _surfaceHi,
border: Border.all(color: _borderHi),
borderRadius: BorderRadius.circular(3),
),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
),
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll(_border),
thickness: const WidgetStatePropertyAll(6),
radius: const Radius.circular(3),
),
);
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll(_border),
thickness: const WidgetStatePropertyAll(6),
radius: const Radius.circular(3),
),
);
}
/// Raw color tokens — use when Material widgets can't carry the meaning.
+29 -29
View File
@@ -63,33 +63,33 @@ class MidnightTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF0B1220),
secondary: _synKeyword,
onSecondary: Color(0xFF0B1220),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0B1220),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF0B1220),
secondary: _synKeyword,
onSecondary: Color(0xFF0B1220),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF0B1220),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
}
+29 -29
View File
@@ -65,33 +65,33 @@ class PaperTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.light(
primary: _accent,
onPrimary: Color(0xFFFBF8F1),
secondary: _info,
onSecondary: Color(0xFFFBF8F1),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFFFBF8F1),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _text, size: 14),
);
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.light(
primary: _accent,
onPrimary: Color(0xFFFBF8F1),
secondary: _info,
onSecondary: Color(0xFFFBF8F1),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFFFBF8F1),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _text, size: 14),
);
}
+30 -30
View File
@@ -65,34 +65,34 @@ class TerminalTheme {
);
static ThemeData get data => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF000000),
secondary: _info,
onSecondary: Color(0xFF000000),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF000000),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
// Terminal mockups go full-mono even for display.
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _bg,
canvasColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF000000),
secondary: _info,
onSecondary: Color(0xFF000000),
surface: _surface,
onSurface: _textHi,
surfaceContainerHighest: _surfaceHi,
outline: _border,
outlineVariant: _borderHi,
error: _err,
onError: Color(0xFF000000),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
// Terminal mockups go full-mono even for display.
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14),
);
}
+1 -7
View File
@@ -10,13 +10,7 @@
import 'dart:ui' show Color;
class ClideTheme {
const ClideTheme({
required this.name,
required this.dark,
required this.subtitle,
required this.palette,
required this.syntax,
});
const ClideTheme({required this.name, required this.dark, required this.subtitle, required this.palette, required this.syntax});
final String name;
final bool dark;
final String subtitle;
+3 -13
View File
@@ -33,23 +33,13 @@ void main() {
tester.view.resetDevicePixelRatio();
});
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+3 -12
View File
@@ -25,22 +25,13 @@ void main() {
tester.view.resetDevicePixelRatio();
});
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+3 -12
View File
@@ -16,22 +16,13 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
preloadNamespaces: const ['builtin.welcome', 'builtin.theme-picker', 'builtin.default-layout'],
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
+62 -116
View File
@@ -38,10 +38,7 @@ class _AppRoot extends StatelessWidget {
debugShowCheckedModeBanner: false,
title: clideName,
color: const Color(0xFF000000),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
settings: settings,
pageBuilder: (ctx, _, __) => builder(ctx),
),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(settings: settings, pageBuilder: (ctx, _, _) => builder(ctx)),
home: _RootShell(services: services),
);
}
@@ -240,35 +237,19 @@ class RootLayout extends StatelessWidget {
child: Row(
children: [
if (sidebarVisible && sidebarCollapsed)
ClideSpine(
label: _sidebarSpineLabel(kernel),
side: SpineSide.left,
onExpand: () => a.setCollapsed(Slots.sidebar, false),
)
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
else if (sidebarVisible) ...[
SizedBox(
width: sidebarSize,
child: SlotHost(slot: Slots.sidebar),
),
DragResizeHandle(
arrangement: a,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
],
const Expanded(child: SlotHost(slot: Slots.workspace)),
if (contextVisible && contextCollapsed)
ClideSpine(
label: 'context',
side: SpineSide.right,
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
)
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
else if (contextVisible) ...[
DragResizeHandle(
arrangement: a,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
SizedBox(
width: contextSize,
child: SlotHost(slot: Slots.contextPanel),
@@ -281,14 +262,18 @@ class RootLayout extends StatelessWidget {
SizedBox(
height: dockHeight,
child: DecoratedBox(
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
),
child: SlotHost(slot: Slots.dock),
),
),
if (statusVisible)
Container(
height: statusHeight,
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -296,12 +281,18 @@ class RootLayout extends StatelessWidget {
// children) so they never shift when a pane collapses (T-294).
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
if (sidebarVisible && !sidebarCollapsed)
SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar))
SizedBox(
width: sidebarSize,
child: _BottomRail(slot: Slots.sidebar),
)
else if (sidebarVisible && sidebarCollapsed)
const SizedBox(width: ClideSpine.width),
const Expanded(child: StatusbarHost()),
if (contextVisible && !contextCollapsed)
SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel))
SizedBox(
width: contextSize,
child: _BottomRail(slot: Slots.contextPanel),
)
else if (contextVisible && contextCollapsed)
const SizedBox(width: ClideSpine.width),
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
@@ -392,11 +383,13 @@ class _RightHatContent extends StatelessWidget {
Widget build(BuildContext context) {
if (kIsWeb) return const SizedBox.shrink();
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
return Row(children: [
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
]);
return Row(
children: [
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
],
);
}
}
@@ -417,11 +410,7 @@ class _WinBtn extends StatelessWidget {
height: hatHeight,
color: hovered ? hoverBg : null,
alignment: Alignment.center,
child: ClideIcon(
icon,
size: 14,
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
),
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
),
);
}
@@ -544,26 +533,29 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: filtered.length,
itemBuilder: (ctx, i) => _RecentProjectRow(
project: filtered[i],
tokens: tokens,
onTap: () => _openProject(filtered[i].path),
),
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
),
),
] else
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: tokens.dividerColor)),
),
child: Column(
children: [
_ActionRow(
label: 'Open Local Project',
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
tokens: tokens,
onTap: () => _runFileCommand('file.openFolder')),
label: 'Open Local Project',
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
tokens: tokens,
onTap: () => _runFileCommand('file.openFolder'),
),
_ActionRow(
label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: () => _runFileCommand('file.newWindow')),
label: 'New Window',
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
tokens: tokens,
onTap: () => _runFileCommand('file.newWindow'),
),
if (widget.kernel.project.isOpen)
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
],
@@ -604,8 +596,14 @@ class _RecentProjectRow extends StatelessWidget {
// Elide a long path instead of overflowing the row
// (matches the welcome recents row; T-160 discipline).
Flexible(
child: ClideText(project.relativePath,
muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
child: ClideText(
project.relativePath,
muted: true,
fontSize: 12,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
@@ -706,10 +704,7 @@ class _SlotHostState extends State<SlotHost> {
return Container(color: tokens.panelBackground);
}
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
final active = tabs.firstWhere(
(t) => t.id == activeId,
orElse: () => tabs.first,
);
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
},
),
@@ -731,21 +726,11 @@ class _SlotBody extends StatelessWidget {
final tokens = ClideTheme.of(context).surface;
if (slot == Slots.sidebar) {
return _SidebarSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
}
if (slot == Slots.contextPanel) {
return _ContextSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
}
if (slot == Slots.workspace) {
@@ -757,9 +742,7 @@ class _SlotBody extends StatelessWidget {
child: Column(
children: [
ClideTabBar(
items: [
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
],
items: [for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t))],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
@@ -774,21 +757,12 @@ class _SlotBody extends StatelessWidget {
final key = t.titleKey;
final ns = t.i18nNamespace;
if (key == null || ns == null) return t.title;
return ClideKernel.of(context).i18n.string(
key,
namespace: ns,
placeholder: t.title,
);
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
}
}
class _SidebarSlot extends StatelessWidget {
const _SidebarSlot({
required this.tabs,
required this.active,
required this.activeId,
required this.onSelect,
});
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
final List<TabContribution> tabs;
final TabContribution active;
@@ -863,10 +837,7 @@ class _WorkspaceSlot extends StatelessWidget {
SizedBox(
height: topHeight,
child: reveal != null
? _RevealedTab(
tab: reveal,
onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId),
)
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
: topTab.build(ctx),
),
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
@@ -903,12 +874,7 @@ class _RevealedTab extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
_SlotBody._resolveTitle(context, tab),
fontSize: clideFontCaption,
color: tokens.panelHeaderForeground,
maxLines: 1,
),
child: ClideText(_SlotBody._resolveTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
),
Semantics(
button: true,
@@ -918,7 +884,7 @@ class _RevealedTab extends StatelessWidget {
child: ClideTappable(
onTap: onClose,
tooltip: 'Close',
builder: (_, hovered, __) => Padding(
builder: (_, hovered, _) => Padding(
padding: const EdgeInsets.all(6),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
@@ -1027,12 +993,7 @@ class _EditorBumpIntent extends Intent {
}
class _ContextSlot extends StatelessWidget {
const _ContextSlot({
required this.tabs,
required this.active,
required this.activeId,
required this.onSelect,
});
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
final List<TabContribution> tabs;
final TabContribution active;
@@ -1042,12 +1003,7 @@ class _ContextSlot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
color: tokens.panelBackground,
alignment: Alignment.topLeft,
padding: const EdgeInsets.only(right: 2),
child: active.build(context),
);
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
}
}
@@ -1068,14 +1024,7 @@ class _BottomRail extends StatelessWidget {
return Container(
color: tokens.chromeBackground,
child: ClideIconRail(
items: [
for (final t in tabs)
ClideIconRailItem(
id: t.id,
icon: _iconFor(slot, t),
tooltip: _SlotBody._resolveTitle(ctx, t),
),
],
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: _SlotBody._resolveTitle(ctx, t))],
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
@@ -1209,10 +1158,7 @@ class _WelcomeOverlay extends StatelessWidget {
builder: (ctx, _) {
if (kernel.project.isOpen) return const SizedBox.shrink();
final tokens = ClideTheme.of(ctx).surface;
return ColoredBox(
color: tokens.globalBackground,
child: const WelcomeView(),
);
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
},
);
}
+8 -31
View File
@@ -42,7 +42,8 @@ const List<String> clideAllowedToolsArgs = ['--allowedTools', clideBashAllowRule
/// the orient-snapshot (`clide status`) and live pane/editor reflection
/// arrive with Epic C (T-218..T-221) and are deliberately left out so the
/// note never points the agent at a command that returns nothing yet.
String clideContextNote(String workspaceRoot) => 'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
String clideContextNote(String workspaceRoot) =>
'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
'surface as a `clide` command on your PATH; drive it with `clide <subsystem> <verb>`. '
'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), '
@@ -61,16 +62,8 @@ String clideContextNote(String workspaceRoot) => 'You are running inside clide,
/// * `CLIDE_WORKSPACE` — the workspace root.
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
/// is not already resolvable), otherwise left untouched.
Map<String, String> agentEnvDelta({
required String workspaceRoot,
required String socketPath,
required String? currentPath,
required String? clideCliDir,
}) {
final delta = <String, String>{
'CLIDE_SOCK': socketPath,
'CLIDE_WORKSPACE': workspaceRoot,
};
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
if (clideCliDir != null && clideCliDir.isNotEmpty) {
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
}
@@ -85,11 +78,7 @@ Map<String, String> agentEnvDelta({
///
/// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes
/// `<dir>/clide`. Both are injected so the resolver is pure and testable.
String? resolveClideCliDir({
required String? currentPath,
required List<String> candidateDirs,
required bool Function(String path) isExecutableFile,
}) {
String? resolveClideCliDir({required String? currentPath, required List<String> candidateDirs, required bool Function(String path) isExecutableFile}) {
if (currentPath != null) {
for (final dir in currentPath.split(':')) {
if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null;
@@ -142,21 +131,9 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
'$workspaceRoot/native/${nativeClideDirName()}',
File(Platform.resolvedExecutable).parent.path,
];
final cliDir = resolveClideCliDir(
currentPath: currentPath,
candidateDirs: candidates,
isExecutableFile: _isExecutableFile,
);
final delta = agentEnvDelta(
workspaceRoot: workspaceRoot,
socketPath: workspaceSocketPath(workspaceRoot),
currentPath: currentPath,
clideCliDir: cliDir,
);
return AgentBootstrap(
envDelta: {...?base, ...delta},
extraArgs: ['--allowedTools', clideBashAllowRule],
);
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
}
bool _isExecutableFile(String path) {
+2 -10
View File
@@ -35,20 +35,12 @@ class ClaudeBanner extends StatelessWidget {
children: [
const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60),
const SizedBox(height: 18),
ClideText(
'Claude',
fontSize: clideFontDialogTitle,
color: claudeAccent,
fontWeight: FontWeight.w500,
),
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
const SizedBox(height: 2),
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
const SizedBox(height: 16),
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
if (statusLine != null) ...[
const SizedBox(height: 2),
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
],
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
const SizedBox(height: 16),
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
],
+11 -27
View File
@@ -352,7 +352,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
}
}
void _applyHistory(String text) => _applyValue(TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)));
void _applyHistory(String text) => _applyValue(
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
),
);
/// Set the controller without it being treated as a user edit (so the
/// preview doesn't overwrite the persisted draft or exit navigation).
@@ -368,10 +373,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
final tokens = _attachments.map((a) => a.pathToken);
if (text.trim().isEmpty && _attachments.isEmpty) return;
// Typed text first, then the attachment @path references.
final message = [
if (text.trim().isNotEmpty) text,
...tokens,
].join(' ');
final message = [if (text.trim().isNotEmpty) text, ...tokens].join(' ');
widget.onSubmit(message);
_controller.clear();
setState(() => _attachments.clear());
@@ -454,11 +456,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final a in _attachments) _chip(theme, a)],
),
child: Wrap(spacing: 6, runSpacing: 6, children: [for (final a in _attachments) _chip(theme, a)]),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -489,13 +487,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
},
child: Stack(
children: [
if (!hasText)
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
if (!hasText) Positioned(left: 0, top: 0, right: 0, child: ClideText(widget.hint, muted: true, fontSize: clideFontBody)),
EditableText(
controller: _controller,
focusNode: _focus,
@@ -516,10 +508,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: PermissionModeControl(
mode: widget.permissionMode!,
onSelect: widget.onSetPermissionMode!,
),
child: PermissionModeControl(mode: widget.permissionMode!, onSelect: widget.onSetPermissionMode!),
),
],
],
@@ -548,12 +537,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_chipLeading(theme, a),
const SizedBox(width: 6),
Flexible(
child: ClideText(
a.fileName,
fontSize: clideFontSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: ClideText(a.fileName, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 4),
Semantics(
+27 -62
View File
@@ -94,13 +94,7 @@ class ClaudePermissions {
/// + plugin + MCP), the skill names, the default model and permission mode.
@immutable
class ClaudeProbe {
const ClaudeProbe({
required this.version,
required this.slashCommands,
required this.skills,
this.model,
this.permissionMode,
});
const ClaudeProbe({required this.version, required this.slashCommands, required this.skills, this.model, this.permissionMode});
final String version;
final List<String> slashCommands;
@@ -109,12 +103,12 @@ class ClaudeProbe {
final String? permissionMode;
Map<String, Object?> toJson() => {
'version': version,
'slash_commands': slashCommands,
'skills': skills,
if (model != null) 'model': model,
if (permissionMode != null) 'permission_mode': permissionMode,
};
'version': version,
'slash_commands': slashCommands,
'skills': skills,
if (model != null) 'model': model,
if (permissionMode != null) 'permission_mode': permissionMode,
};
/// Build from a stream-json `init` event object. Returns null if it doesn't
/// look like an init event (no version field).
@@ -130,12 +124,12 @@ class ClaudeProbe {
}
static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe(
version: (j['version'] as String?) ?? '',
slashCommands: _stringList(j['slash_commands']),
skills: _stringList(j['skills']),
model: j['model'] as String?,
permissionMode: j['permission_mode'] as String?,
);
version: (j['version'] as String?) ?? '',
slashCommands: _stringList(j['slash_commands']),
skills: _stringList(j['skills']),
model: j['model'] as String?,
permissionMode: j['permission_mode'] as String?,
);
}
List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const [];
@@ -184,13 +178,13 @@ class ClaudeConfig extends ChangeNotifier {
ClaudeInitProbe? initProbe,
ClaudeConfigWatch? watch,
Duration debounce = const Duration(milliseconds: 150),
}) : _globalDir = globalDir,
_cacheDir = cacheDir,
_projectDir = projectDir,
_versionRunner = versionRunner ?? _defaultVersionRunner,
_initProbe = initProbe ?? _defaultInitProbe,
_watch = watch,
_debounceFor = debounce;
}) : _globalDir = globalDir,
_cacheDir = cacheDir,
_projectDir = projectDir,
_versionRunner = versionRunner ?? _defaultVersionRunner,
_initProbe = initProbe ?? _defaultInitProbe,
_watch = watch,
_debounceFor = debounce;
final Directory _globalDir;
final Directory _cacheDir;
@@ -400,10 +394,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Global first so that local entries, added later, win on collisions.
List<(ConfigScope, Directory)> _scopeDirs() {
final pd = _projectDir;
return [
(ConfigScope.global, _globalDir),
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
];
return [(ConfigScope.global, _globalDir), if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude'))];
}
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
@@ -415,12 +406,7 @@ class ClaudeConfig extends ChangeNotifier {
final manifest = File('${entry.path}/SKILL.md');
if (!await manifest.exists()) continue;
final fm = _parseFrontmatter(await manifest.readAsString());
out.add(ClaudeSkill(
name: fm.name ?? _basename(entry.path),
description: fm.description,
scope: scope,
path: manifest.path,
));
out.add(ClaudeSkill(name: fm.name ?? _basename(entry.path), description: fm.description, scope: scope, path: manifest.path));
}
return out;
}
@@ -432,11 +418,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeCommand(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
}
return out;
}
@@ -449,11 +431,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path);
out.add(ClaudeAgent(
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
out.add(ClaudeAgent(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
}
return out;
}
@@ -472,11 +450,7 @@ class ClaudeConfig extends ChangeNotifier {
ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
final p = settings['permissions'];
if (p is! Map) return const ClaudePermissions();
return ClaudePermissions(
allow: _stringList(p['allow']),
deny: _stringList(p['deny']),
ask: _stringList(p['ask']),
);
return ClaudePermissions(allow: _stringList(p['allow']), deny: _stringList(p['deny']), ask: _stringList(p['ask']));
}
// ---- Watching -----------------------------------------------------------
@@ -573,9 +547,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
if (raw is! Map) return const [];
return [
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
];
return [for (final key in raw.keys) ClaudeMcpServer(name: '$key')];
}
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
@@ -608,14 +580,7 @@ Future<String?> _defaultVersionRunner() async {
Future<String?> _defaultInitProbe() async {
try {
final r = await Process.run('claude', [
'-p',
'.',
'--no-session-persistence',
'--output-format',
'stream-json',
'--verbose',
]);
final r = await Process.run('claude', ['-p', '.', '--no-session-persistence', '--output-format', 'stream-json', '--verbose']);
return r.stdout as String?;
} catch (_) {
return null;
+96 -173
View File
@@ -218,14 +218,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final managed = orch.byMemberName(memberName);
if (managed == null) return;
final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}';
unawaited(orch.spawn(SpawnSpec(
id: forkId,
role: 'fork of $memberName',
// sessionId is a placeholder; real claude session id arrives via init.
sessionId: forkId,
cwd: managed.cwd,
forkSourceSessionId: managed.sessionId,
)));
unawaited(
orch.spawn(
SpawnSpec(
id: forkId,
role: 'fork of $memberName',
// sessionId is a placeholder; real claude session id arrives via init.
sessionId: forkId,
cwd: managed.cwd,
forkSourceSessionId: managed.sessionId,
),
),
);
}
Future<void> _refreshStats() async {
@@ -276,11 +280,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_MetaRow('sessions', '${latest.sessionCount}'),
_MetaRow('tool calls', '${latest.toolCallCount}'),
]),
if (latest != null)
_MetaSection('LIFETIME', [
_MetaRow('messages', '${_stats.lifetimeMessages}'),
_MetaRow('sessions', '${_stats.lifetimeSessions}'),
]),
if (latest != null) _MetaSection('LIFETIME', [_MetaRow('messages', '${_stats.lifetimeMessages}'), _MetaRow('sessions', '${_stats.lifetimeSessions}')]),
..._runtimeSection(tokens),
];
if (sections.isEmpty) {
@@ -357,17 +357,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final broker = _orchestrator?.broker;
if (chatModel != null && broker != null) {
children.add(const SizedBox(height: 12));
children.add(TeamChatSidebar(
model: chatModel,
broker: broker,
onPopOut: _openChatPane,
));
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: _openChatPane));
}
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
return ListView(padding: const EdgeInsets.all(12), children: children);
}
Widget _taskSection(SurfaceTokens tokens) {
@@ -420,18 +413,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// Footer hint.
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClideText(
'expand a list to see all · click a skill/agent/command → opens its .md',
muted: true,
fontSize: clideFontSmall,
),
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
),
];
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
return ListView(padding: const EdgeInsets.all(12), children: children);
}
/// One key→value row in the pinned SETTINGS table.
@@ -454,22 +440,22 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
}
String _configSectionLabel(_ConfigSection section) => switch (section) {
_ConfigSection.skills => 'SKILLS',
_ConfigSection.agents => 'AGENTS',
_ConfigSection.commands => 'COMMANDS',
_ConfigSection.hooks => 'HOOKS',
_ConfigSection.permissions => 'PERMISSIONS',
_ConfigSection.mcpServers => 'MCP SERVERS',
};
_ConfigSection.skills => 'SKILLS',
_ConfigSection.agents => 'AGENTS',
_ConfigSection.commands => 'COMMANDS',
_ConfigSection.hooks => 'HOOKS',
_ConfigSection.permissions => 'PERMISSIONS',
_ConfigSection.mcpServers => 'MCP SERVERS',
};
int _configSectionCount(ClaudeConfig config, _ConfigSection section) => switch (section) {
_ConfigSection.skills => config.skills.length,
_ConfigSection.agents => config.agents.length,
_ConfigSection.commands => config.commands.length,
_ConfigSection.hooks => config.hooks.length,
_ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
_ConfigSection.mcpServers => config.mcpServers.length,
};
_ConfigSection.skills => config.skills.length,
_ConfigSection.agents => config.agents.length,
_ConfigSection.commands => config.commands.length,
_ConfigSection.hooks => config.hooks.length,
_ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
_ConfigSection.mcpServers => config.mcpServers.length,
};
Widget _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
final expanded = _expanded.contains(section);
@@ -492,17 +478,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
switch (section) {
case _ConfigSection.skills:
return [
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
];
return [for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path)];
case _ConfigSection.agents:
return [
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
];
return [for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path)];
case _ConfigSection.commands:
return [
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
];
return [for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path)];
case _ConfigSection.hooks:
return [
for (final hook in config.hooks)
@@ -540,11 +520,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
Widget _configFileRow(SurfaceTokens tokens, String name, String? path) {
final row = Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: path != null ? tokens.globalFocus : tokens.globalForeground,
),
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
);
if (path == null) return row;
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
@@ -558,11 +534,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
onTap: openMarkdown,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: hovered ? tokens.globalForeground : tokens.globalFocus,
),
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
),
),
);
@@ -576,22 +548,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// allow → statusSuccess, ask → statusWarning, deny → statusError
Color kindColor(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => tokens.statusSuccess,
_ConfigPermKind.ask => tokens.statusWarning,
_ConfigPermKind.deny => tokens.statusError,
};
_ConfigPermKind.allow => tokens.statusSuccess,
_ConfigPermKind.ask => tokens.statusWarning,
_ConfigPermKind.deny => tokens.statusError,
};
String kindLabel(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => kindAllow,
_ConfigPermKind.ask => kindAsk,
_ConfigPermKind.deny => kindDeny,
};
_ConfigPermKind.allow => kindAllow,
_ConfigPermKind.ask => kindAsk,
_ConfigPermKind.deny => kindDeny,
};
final groups = [
(_ConfigPermKind.allow, perms.allow),
(_ConfigPermKind.ask, perms.ask),
(_ConfigPermKind.deny, perms.deny),
];
final groups = [(_ConfigPermKind.allow, perms.allow), (_ConfigPermKind.ask, perms.ask), (_ConfigPermKind.deny, perms.deny)];
final rows = <Widget>[];
for (final (kind, rules) in groups) {
@@ -631,44 +599,41 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// --- Shared rendering -----------------------------------------------------
Widget _placeholder(String text) => Padding(
padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall),
);
padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall),
);
Widget _metaTable(SurfaceTokens tokens, List<_MetaSection> sections) {
final children = <Widget>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
));
children.add(
Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
);
for (final r in s.rows) {
children.add(Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
),
Expanded(
child: ClideText(
r.value,
fontSize: clideFontSmall,
color: r.valueColor ?? tokens.globalForeground,
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
),
),
],
Expanded(
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
),
],
),
),
));
);
}
}
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
return ListView(padding: const EdgeInsets.all(12), children: children);
}
}
@@ -788,7 +753,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
// Color dot
Padding(
padding: const EdgeInsets.only(top: 3),
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
),
const SizedBox(width: 8),
// Name + status
@@ -840,11 +809,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
child: Row(
children: [
Expanded(
child: ClideText(
'Enable bypassPermissions? All tool calls will be auto-allowed.',
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
const SizedBox(width: 4),
// Confirm
@@ -889,14 +854,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
);
}
Widget _buildControls(
BuildContext context,
SurfaceTokens tokens,
ManagedSession managed,
bool isVisible,
bool isMuted,
bool isInjecting,
) {
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -975,11 +933,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
/// Maps a permission-mode string to a single-letter badge label.
String _permissionModeBadge(String mode) => switch (mode) {
'acceptEdits' => 'A',
'plan' => 'P',
'bypassPermissions' => 'B',
_ => 'D', // default
};
'acceptEdits' => 'A',
'plan' => 'P',
'bypassPermissions' => 'B',
_ => 'D', // default
};
/// Clickable permission-mode badge shown in each roster row (T-181).
///
@@ -990,12 +948,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
/// It is a custom painted label (no Material), consistent with the rendering
/// stack rules (D-7, CLAUDE.md guardrails).
class _PermissionModeBadge extends StatelessWidget {
const _PermissionModeBadge({
required this.mode,
required this.tokens,
required this.onCycle,
required this.onBypass,
});
const _PermissionModeBadge({required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
final String mode;
final SurfaceTokens tokens;
@@ -1012,7 +965,8 @@ class _PermissionModeBadge extends StatelessWidget {
final isBypass = mode == 'bypassPermissions';
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. '
final tooltip =
'Permission mode: ${permissionModeLabel(mode)}. '
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
return Padding(
@@ -1046,11 +1000,7 @@ class _PermissionModeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(2),
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
),
child: ClideText(
label,
fontSize: 9,
color: badgeColor,
),
child: ClideText(label, fontSize: 9, color: badgeColor),
),
),
),
@@ -1060,12 +1010,7 @@ class _PermissionModeBadge extends StatelessWidget {
/// A single icon-button used in the roster row controls.
class _IconButton extends StatelessWidget {
const _IconButton({
required this.painter,
required this.tooltip,
required this.color,
required this.onTap,
});
const _IconButton({required this.painter, required this.tooltip, required this.color, required this.onTap});
final ClideIconPainter painter;
final String tooltip;
@@ -1096,11 +1041,7 @@ class _IconButton extends StatelessWidget {
/// Inline text input for injecting a message into a session (T-171).
/// Submits on Enter; Cancel is handled by the parent via [_IconButton].
class _InjectTextField extends StatelessWidget {
const _InjectTextField({
required this.controller,
required this.tokens,
required this.onSubmit,
});
const _InjectTextField({required this.controller, required this.tokens, required this.onSubmit});
final TextEditingController controller;
final SurfaceTokens tokens;
@@ -1119,12 +1060,7 @@ class _InjectTextField extends StatelessWidget {
child: EditableText(
controller: controller,
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit,
@@ -1139,11 +1075,7 @@ class _InjectTextField extends StatelessWidget {
/// One row in the TASKS section: status marker + title + owner + reassign.
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
required this.members,
required this.broker,
});
const _TaskRow({required this.task, required this.members, required this.broker});
final TeamTask task;
final List<TeamMemberJoined> members;
@@ -1240,18 +1172,9 @@ class _TabStrip extends StatelessWidget {
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.only(bottom: 3),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: t == current ? tokens.globalFocus : const Color(0x00000000),
width: 2,
),
),
),
child: ClideText(
_label(t),
fontSize: clideFontSmall,
color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted,
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
),
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
@@ -1262,8 +1185,8 @@ class _TabStrip extends StatelessWidget {
}
String _label(SidebarTab t) => switch (t) {
SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config',
};
SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config',
};
}
+29 -54
View File
@@ -197,9 +197,12 @@ class _ClaudePaneState extends State<ClaudePane> {
sub.cancel();
if (!c.isCompleted) c.complete();
});
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
sub.cancel();
});
await c.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
sub.cancel();
},
);
if (!mounted) return;
}
return _spawn();
@@ -267,13 +270,9 @@ class _ClaudePaneState extends State<ClaudePane> {
// assigned by `--fork-session` and arrives in the init event (T-172).
_sessionId ??= freshSessionId();
try {
managed = await orch.spawn(SpawnSpec(
id: _orchId,
role: 'fork ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
));
managed = await orch.spawn(
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
);
} catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e');
return;
@@ -291,14 +290,16 @@ class _ClaudePaneState extends State<ClaudePane> {
final resume = await File(transcriptFile).exists();
try {
managed = await orch.spawn(SpawnSpec(
id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
));
managed = await orch.spawn(
SpawnSpec(
id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
),
);
} catch (e) {
if (mounted) setState(() => _error = 'Could not start claude: $e');
return;
@@ -314,10 +315,10 @@ class _ClaudePaneState extends State<ClaudePane> {
// from the transcript/sidecar). Surfaces the resume path in `make run`.
final seeded = _conversation?.items.length ?? 0;
_kernel()?.log.info(
'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
);
'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
);
_statusSub = managed.session.statusStream.listen((s) {
if (!mounted) return;
setState(() => _status = s);
@@ -426,13 +427,7 @@ class _ClaudePaneState extends State<ClaudePane> {
final dir = Directory(claudeProjectDir(root));
final sessions = await listSessions(dir);
if (!mounted) return;
final picked = await dialog.show<String>(
(ctx, dismiss) => SessionPickerDialog(
sessions: sessions,
onPick: (id) => dismiss(id),
onCancel: dismiss,
),
);
final picked = await dialog.show<String>((ctx, dismiss) => SessionPickerDialog(sessions: sessions, onPick: (id) => dismiss(id), onCancel: dismiss));
if (picked == null || !mounted) return;
setState(() => _statusLine = 'resuming…');
await _respawnWithSession(picked);
@@ -479,10 +474,7 @@ class _ClaudePaneState extends State<ClaudePane> {
final Widget body;
if (_error != null) {
body = Padding(
padding: const EdgeInsets.all(16),
child: ClideText(_error!, muted: true),
);
body = Padding(padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true));
} else if (_conversation != null) {
// Rebuild conversation + composer zone together on each prompt change so
// the view hides a prompted tool-use card the moment its prompt appears
@@ -520,7 +512,7 @@ class _ClaudePaneState extends State<ClaudePane> {
// with the conversation; renders nothing when there are no tasks.
ListenableBuilder(
listenable: _conversation!,
builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
builder: (_, _) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
),
// An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the
@@ -557,22 +549,11 @@ class _ClaudePaneState extends State<ClaudePane> {
body = const Center(child: ClideText('starting…', muted: true));
}
final content = widget.showChrome
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
child: body,
)
: body;
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
// Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150).
return ClidePane(
contributionId: widget.contributionId,
active: widget.active,
statusWidget: _statusWidget(tokens),
child: content,
);
return ClidePane(contributionId: widget.contributionId, active: widget.active, statusWidget: _statusWidget(tokens), child: content);
}
}
@@ -590,13 +571,7 @@ class _ModeBadge extends StatelessWidget {
return Semantics(
label: 'permission mode: ${permissionModeLabel(mode)}',
excludeSemantics: true,
child: ClideText(
permissionModeLabel(mode),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: permissionModeColor(mode, tokens),
maxLines: 1,
),
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
);
}
}
+14 -10
View File
@@ -85,11 +85,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// Public entry point used by the `claude.new-secondary` command.
void addSecondary() {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
));
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
),
);
}
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// without touching the original.
void addFork(String sourceClaudeSessionId) {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
));
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
),
);
}
@override
+9 -12
View File
@@ -8,12 +8,7 @@ library;
import 'dart:convert';
class DailyActivity {
const DailyActivity({
required this.date,
required this.messageCount,
required this.sessionCount,
required this.toolCallCount,
});
const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
final int messageCount;
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
if (da is List) {
for (final e in da) {
if (e is! Map) continue;
daily.add(DailyActivity(
date: '${e['date']}',
messageCount: _int(e['messageCount']),
sessionCount: _int(e['sessionCount']),
toolCallCount: _int(e['toolCallCount']),
));
daily.add(
DailyActivity(
date: '${e['date']}',
messageCount: _int(e['messageCount']),
sessionCount: _int(e['sessionCount']),
toolCallCount: _int(e['toolCallCount']),
),
);
}
}
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily);
+1 -4
View File
@@ -62,10 +62,7 @@ String nextSafePermissionMode(String current) {
if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
if (s.rateLimitInfo != null) s.rateLimitInfo!,
].join(' · ');
return (
leading: s.model != null ? shortModelLabel(s.model!) : null,
trailing: trailing.isEmpty ? null : trailing,
);
return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
}
/// Friendly label for Claude's permission modes.
+22 -24
View File
@@ -62,10 +62,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
ClideDivider(),
Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final t in tasks) _taskRow(tokens, t)],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [for (final t in tasks) _taskRow(tokens, t)]),
),
],
],
@@ -76,20 +73,22 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
}
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[
const SizedBox(width: 10),
Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)),
] else
const Spacer(),
],
),
);
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8),
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[
const SizedBox(width: 10),
Expanded(
child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
),
] else
const Spacer(),
],
),
);
Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
final (String glyph, Color color, String word) = switch (t.status) {
@@ -106,14 +105,13 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)),
Padding(
padding: const EdgeInsets.only(top: 1),
child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color),
),
const SizedBox(width: 8),
Expanded(
child: ClideText(
t.text,
fontSize: clideFontCaption,
color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground,
),
child: ClideText(t.text, fontSize: clideFontCaption, color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground),
),
],
),
+2 -8
View File
@@ -99,16 +99,10 @@ String pasteCacheDir() {
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
/// path. Returns an empty list when the clipboard holds neither, so the
/// composer pastes text instead.
Future<List<ComposerAttachment>> resolveClipboardAttachment(
ClipboardSource source, {
Directory? tempDir,
DateTime Function() now = DateTime.now,
}) async {
Future<List<ComposerAttachment>> resolveClipboardAttachment(ClipboardSource source, {Directory? tempDir, DateTime Function() now = DateTime.now}) async {
final files = await source.readFiles();
if (files.isNotEmpty) {
return [
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
];
return [for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p))];
}
final image = await source.readImage();
+13 -29
View File
@@ -183,20 +183,13 @@ class _ConversationCardState extends State<ConversationCard> {
if (!_collapsed) ...[
const SizedBox(height: 4),
widget.body,
for (final seg in widget.extraSegments) ...[
_segmentLabel(tokens, seg.label),
seg.child,
],
for (final seg in widget.extraSegments) ...[_segmentLabel(tokens, seg.label), seg.child],
],
],
);
return Padding(
padding: widget.margin,
child: MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: _frame(tokens, content),
),
child: MouseRegion(onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: _frame(tokens, content)),
);
}
@@ -289,11 +282,7 @@ class _ConversationCardState extends State<ConversationCard> {
onTap: () => setState(() => _collapsed = !_collapsed),
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'),
size: 12,
color: tokens.globalTextMuted,
),
child: ClideIcon(_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'), size: 12, color: tokens.globalTextMuted),
),
),
);
@@ -330,15 +319,15 @@ class _ConversationCardState extends State<ConversationCard> {
/// A muted sub-label + hairline divider introducing an [CardSegment] below
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
],
),
);
List<Widget> _actions(SurfaceTokens tokens) {
final items = <_ActionItem>[];
@@ -360,12 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
onTap: items[i].onTap,
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(
items[i].label,
fontSize: clideFontMeta,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
),
),
@@ -21,11 +21,8 @@ class ConversationController extends ChangeNotifier {
/// over stream-json so the pane would otherwise start empty. [onDispose]
/// is invoked from [dispose] — wire it to the reader's `dispose` so
/// cancelling the view tears down the underlying tail.
ConversationController({
required Stream<ConversationItem> stream,
Iterable<ConversationItem>? seed,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose {
ConversationController({required Stream<ConversationItem> stream, Iterable<ConversationItem>? seed, Future<void> Function()? onDispose})
: _onDispose = onDispose {
if (seed != null) _items.addAll(seed);
_sub = stream.listen(_onItem);
}
@@ -34,13 +31,10 @@ class ConversationController extends ChangeNotifier {
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({
required MessageBus messages,
String channel = ClaudeConversation.leadChannel,
Future<void> Function()? onDispose,
}) {
final stream =
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
final stream = messages
.subscribe(publisher: ClaudeConversation.publisher, channel: channel)
.map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
return ConversationController(stream: stream, onDispose: onDispose);
}
+117 -129
View File
@@ -181,11 +181,9 @@ class _ConversationViewState extends State<ConversationView> {
/// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
/// nested in a holder UNDER the Agent card (T-264), with a successful
/// sidechain tool result left out (it folds into its own tool card).
({
Set<String> ownedSidechainUuids,
Map<String, List<UserMessage>> promptsByToolUseId,
Map<String, List<ConversationItem>> runByToolUseId,
}) _sidechainFold(List<ConversationItem> items) {
({Set<String> ownedSidechainUuids, Map<String, List<UserMessage>> promptsByToolUseId, Map<String, List<ConversationItem>> runByToolUseId}) _sidechainFold(
List<ConversationItem> items,
) {
final agentByMsgUuid = <String, AssistantToolUse>{
for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
@@ -308,39 +306,39 @@ class _ConversationViewState extends State<ConversationView> {
// visible list don't reattach State to the wrong card (T-285).
return switch (g) {
StickyItem(:final item) => _ConversationTurn(
key: ValueKey('turn.${item.uuid}'),
item: item,
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('turn.${item.uuid}'),
item: item,
tokens: tokens,
collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'),
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('cluster.${items.first.uuid}'),
items: items,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'),
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
key: ValueKey('edits.${edits.first.uuid}'),
edits: edits,
tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
),
};
},
),
@@ -413,7 +411,7 @@ String? resolveWorkspaceFilePath(String? root, String raw) {
/// Open a clicked workspace file reference in the editor, jumping to [line]
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
void _openFile(BuildContext context, String path, int? line) {
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
}
/// One conversation item, rendered by kind.
@@ -474,63 +472,63 @@ class _ConversationTurn extends StatelessWidget {
// sidechain prompt here is an orphan one (its Agent card couldn't be
// resolved) — folded prompts are suppressed upstream (T-263).
UserMessage() when i.injected || i.isSidechain => ConversationCard(
// Framed like every other card (T-306) — just muted + collapsed, not
// the blue "you" accent (D-78); bare read as unfinished next to the
// carded tool calls.
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
// Framed like every other card (T-306) — just muted + collapsed, not
// the blue "you" accent (D-78); bare read as unfinished next to the
// carded tool calls.
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.text),
margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
),
UserMessage() => ConversationCard(
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
margin: _childMargin,
// Pasted-image @path tokens render as inline thumbnails that open the
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
accent: tokens.globalFocus,
label: 'you',
copyText: i.text,
margin: _childMargin,
// Pasted-image @path tokens render as inline thumbnails that open the
// lightbox (T-236/T-254); copyText keeps the original text verbatim.
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
// agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude.
AssistantTextMessage() => ConversationCard(
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
AssistantThinkingMessage() => ConversationCard(
// Framed + muted like the context card (T-306).
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent thinking' : 'thinking',
copyText: i.thinking,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.thinking),
margin: _childMargin,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
),
// Framed + muted like the context card (T-306).
variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent thinking' : 'thinking',
copyText: i.thinking,
collapsible: true,
collapsedByDefault: true,
collapsedSummary: _firstLine(i.thinking),
margin: _childMargin,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
),
AssistantToolUse() => collapseTools ? _toolUseCollapser(i) : _toolContentCard(i),
ToolResultMessage() => _toolResult(i),
ImageMessage() => _image(context, i),
@@ -566,16 +564,13 @@ class _ConversationTurn extends StatelessWidget {
image: ClideFileImage(m.path),
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
errorBuilder: (_, _, _) => _imagePlaceholder(m.path),
),
),
),
),
),
if (caption != null && caption.isNotEmpty) ...[
const SizedBox(height: 4),
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
],
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
],
),
);
@@ -583,34 +578,30 @@ class _ConversationTurn extends StatelessWidget {
void _openLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
),
),
);
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
),
);
}
Widget _imagePlaceholder(String path) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
],
),
);
],
),
);
/// A standalone tool use (T-305): every tool use is a collapser over a
/// one-item list. The collapser carries the echoed last line, the count, and
@@ -685,8 +676,15 @@ class _ConversationTurn extends StatelessWidget {
// (note E): call input (body) → prompt → returned result.
final segments = <CardSegment>[
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
if (succeeded && !(isAgent && hasRun)) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
CardSegment(
label: 'prompt',
child: ClideText(p.text, muted: true, fontSize: clideFontMeta),
),
if (succeeded && !(isAgent && hasRun))
CardSegment(
label: 'result',
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
),
];
// A resolved permission-prompted call is tinted green if approved / red if
@@ -757,12 +755,7 @@ class _ConversationTurn extends StatelessWidget {
collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
body: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: quiet ? tokens.globalTextMuted : tokens.statusError,
),
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
);
}
@@ -783,12 +776,7 @@ class _ConversationTurn extends StatelessWidget {
collapsedSummary: multiline ? _firstLine(t.content) : null,
body: isOutputTool
? ClideCodeBlock(source: t.content, language: 'text')
: ClideText(
t.content,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
),
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
);
}
+263 -274
View File
@@ -48,270 +48,259 @@ class ClaudeExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 90,
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
),
CommandContribution(
id: 'claude.new-secondary',
command: 'claude.new-secondary',
title: 'Claude: open a secondary session',
run: (_) async {
_hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
},
),
CommandContribution(
id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
id: 'claude.session-storage',
command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage,
),
// T-235: cycle how aggressively the activity card folds meta steps
// (none → tools → thinking → everything), persisted app-wide. The panes
// read kActivityFoldLevelKey and rebuild via the settings notifier.
CommandContribution(
id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level',
run: _cycleFoldLevel,
),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId>
CommandContribution(
id: 'claude.agent.show',
command: 'claude.agent.show',
title: 'Claude: show an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
},
),
CommandContribution(
id: 'claude.agent.hide',
command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
},
),
CommandContribution(
id: 'claude.agent.close',
command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
},
),
CommandContribution(
id: 'claude.agent.mute',
command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
},
),
CommandContribution(
id: 'claude.agent.unmute',
command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
},
),
// Usage: clide claude.agent.inject-message <sessionId> <text...>
CommandContribution(
id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final text = args.skip(1).join(' ');
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
_orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
},
),
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
// UI's confirm dialog; the CLI caller is responsible for their own safety.
CommandContribution(
id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) {
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
}
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
},
),
// T-226: cycle the primary session's permission mode through the safe
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
CommandContribution(
id: 'claude.mode.cycle',
command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode',
run: (_) async {
final managed = _orchestrator?.byId('primary');
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
},
),
// Usage: clide claude.task.reassign <taskId> <toSessionId>
CommandContribution(
id: 'claude.task.reassign',
command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent',
run: (args) async {
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
final taskId = args[0];
final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
},
),
// T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget.
TabContribution(
id: 'claude.team-chat',
slot: Slots.workspace,
title: 'Team Chat',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 85,
build: (_) {
final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
},
),
// CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open
CommandContribution(
id: 'claude.team-chat.open',
command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane',
run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
},
),
// Usage: clide claude.team-chat.post [@name] <text...>
// Posts a message into the broker channel as the user.
// Leading @name tag selects the recipient; omit for broadcast.
CommandContribution(
id: 'claude.team-chat.post',
command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user',
run: (args) async {
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
final raw = args.join(' ');
String? recipient;
String body = raw;
if (raw.startsWith('@')) {
final ws = raw.indexOf(RegExp(r'\s'));
if (ws > 0) {
final tag = raw.substring(1, ws);
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
body = raw.substring(ws).trim();
}
}
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
},
),
// claude.agent.fork: branch a managed session into a new fork session
// (T-172, D-6 CLI/UI parity for the roster fork button).
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: optional working directory; defaults to the source session's cwd.
CommandContribution(
id: 'claude.agent.fork',
command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session',
run: (args) async {
final sourceId = args.firstOrNull;
if (sourceId == null) {
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
}
final orch = _orchestrator;
if (orch == null) {
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
}
final source = orch.byId(sourceId);
if (source == null) {
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec(
id: forkId,
role: 'fork of $sourceId',
sessionId: forkId,
cwd: cwd,
forkSourceSessionId: source.sessionId,
));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
},
),
// Always-pickable left-panel tab: Claude activity (from
// stats-cache.json) + the team roster when a team is running (T-141).
TabContribution(
id: 'claude.meta',
slot: Slots.sidebar,
title: 'Activity',
icon: PhosphorIcons.byName('robot'),
priority: 60,
build: (_) => const ClaudeMetaSidebar(),
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
];
TabContribution(
id: 'claude.primary',
slot: Slots.workspace,
title: 'Claude',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 90,
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
),
CommandContribution(
id: 'claude.new-secondary',
command: 'claude.new-secondary',
title: 'Claude: open a secondary session',
run: (_) async {
_hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
},
),
CommandContribution(
id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
id: 'claude.session-storage',
command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage,
),
// T-235: cycle how aggressively the activity card folds meta steps
// (none → tools → thinking → everything), persisted app-wide. The panes
// read kActivityFoldLevelKey and rebuild via the settings notifier.
CommandContribution(
id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level',
run: _cycleFoldLevel,
),
// T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId>
CommandContribution(
id: 'claude.agent.show',
command: 'claude.agent.show',
title: 'Claude: show an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
},
),
CommandContribution(
id: 'claude.agent.hide',
command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
},
),
CommandContribution(
id: 'claude.agent.close',
command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
},
),
CommandContribution(
id: 'claude.agent.mute',
command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
},
),
CommandContribution(
id: 'claude.agent.unmute',
command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
},
),
// Usage: clide claude.agent.inject-message <sessionId> <text...>
CommandContribution(
id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final text = args.skip(1).join(' ');
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
_orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
},
),
// T-181: set permission mode for an agent session (D-6 CLI/UI parity).
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
// Note: bypassPermissions is accepted via CLI — the footgun guard is the
// UI's confirm dialog; the CLI caller is responsible for their own safety.
CommandContribution(
id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) {
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
}
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
},
),
// T-226: cycle the primary session's permission mode through the safe
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
CommandContribution(
id: 'claude.mode.cycle',
command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode',
run: (_) async {
final managed = _orchestrator?.byId('primary');
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
},
),
// Usage: clide claude.task.reassign <taskId> <toSessionId>
CommandContribution(
id: 'claude.task.reassign',
command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent',
run: (args) async {
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
final taskId = args[0];
final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
},
),
// T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget.
TabContribution(
id: 'claude.team-chat',
slot: Slots.workspace,
title: 'Team Chat',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 85,
build: (_) {
final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker);
},
),
// CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open
CommandContribution(
id: 'claude.team-chat.open',
command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane',
run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'});
},
),
// Usage: clide claude.team-chat.post [@name] <text...>
// Posts a message into the broker channel as the user.
// Leading @name tag selects the recipient; omit for broadcast.
CommandContribution(
id: 'claude.team-chat.post',
command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user',
run: (args) async {
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
final raw = args.join(' ');
String? recipient;
String body = raw;
if (raw.startsWith('@')) {
final ws = raw.indexOf(RegExp(r'\s'));
if (ws > 0) {
final tag = raw.substring(1, ws);
recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
body = raw.substring(ws).trim();
}
}
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
},
),
// claude.agent.fork: branch a managed session into a new fork session
// (T-172, D-6 CLI/UI parity for the roster fork button).
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: optional working directory; defaults to the source session's cwd.
CommandContribution(
id: 'claude.agent.fork',
command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session',
run: (args) async {
final sourceId = args.firstOrNull;
if (sourceId == null) {
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
}
final orch = _orchestrator;
if (orch == null) {
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
}
final source = orch.byId(sourceId);
if (source == null) {
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
},
),
// Always-pickable left-panel tab: Claude activity (from
// stats-cache.json) + the team roster when a team is running (T-141).
TabContribution(
id: 'claude.meta',
slot: Slots.sidebar,
title: 'Activity',
icon: PhosphorIcons.byName('robot'),
priority: 60,
build: (_) => const ClaudeMetaSidebar(),
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
@@ -399,13 +388,15 @@ class ClaudeExtension extends ClideExtension {
}
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
));
target.conversation.inject(
ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
),
);
}
@override
@@ -466,9 +457,7 @@ class ClaudeExtension extends ClideExtension {
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
final sessions = await listSessions(dir);
await ctx.dialog.show<Object>(
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
);
await ctx.dialog.show<Object>((c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss));
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
}
}
+6 -16
View File
@@ -17,15 +17,11 @@ import 'package:flutter/widgets.dart';
/// Open [path] full-size in the lightbox via the kernel dialog router.
void openImageLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
),
),
);
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
),
);
}
Widget _placeholder(BuildContext context, double size) {
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image(
image: ClideFileImage(path),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
),
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
),
),
),
@@ -67,24 +67,24 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
}
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens),
label: permissionModeLabel(m),
active: m == widget.mode,
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
),
];
for (final m in kSafePermissionCycle)
ClideMenuItem(
leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens),
label: permissionModeLabel(m),
active: m == widget.mode,
onSelect: () => widget.onSelect(m),
),
const ClideMenuSeparator(),
ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'),
enabled: false,
active: widget.mode == 'bypassPermissions',
onSelect: () {},
),
];
@override
Widget build(BuildContext context) {
@@ -94,11 +94,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu(
onClose: ctrl.close,
minWidth: 180,
entries: _entries(ClideTheme.of(ctx).surface),
),
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
anchor: ListenableBuilder(
listenable: _overlay,
builder: (ctx, _) {
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
),
border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
borderRadius: BorderRadius.circular(4),
),
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
+52 -58
View File
@@ -27,7 +27,8 @@ const _kOther = '\u0000other';
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny
/// THIS action and ask Claude to reformulate it more simply, explicitly without
/// touching the permission surface (memories / settings).
const _kDenySimplifyNote = 'Denied — this action is too complex for the permission system to approve cleanly. '
const _kDenySimplifyNote =
'Denied — this action is too complex for the permission system to approve cleanly. '
'Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) '
'to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not '
'change permission settings — just reformulate and try again. Do not narrate or explain the change; '
@@ -295,11 +296,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
[
if (_q.isNotEmpty) _questionBody(tokens, 0),
const SizedBox(height: 6),
Row(children: [
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
const Spacer(),
_chatInstead(tokens),
]),
Row(
children: [
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
const Spacer(),
_chatInstead(tokens),
],
),
],
);
}
@@ -312,11 +315,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 8),
for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i),
const SizedBox(height: 6),
Row(children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
const SizedBox(width: 8),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
]),
Row(
children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
const SizedBox(width: 8),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
],
),
],
);
}
@@ -330,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 10),
_questionBody(tokens, _step),
const SizedBox(height: 6),
Row(children: [
if (_step > 0) ...[
ClideButton(label: ' Back', onPressed: () => setState(() => _step--)),
const SizedBox(width: 8),
Row(
children: [
if (_step > 0) ...[ClideButton(label: ' Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
ClideButton(label: last ? 'Review ' : 'Next ', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
const Spacer(),
_chatInstead(tokens),
],
ClideButton(
label: last ? 'Review ' : 'Next ',
variant: ClideButtonVariant.primary,
onPressed: answered ? () => setState(() => _step++) : null,
),
const Spacer(),
_chatInstead(tokens),
]),
),
],
);
}
@@ -354,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
final done = _answer(i).isNotEmpty;
final text = '${i + 1} · $head${done ? '' : ''}';
if (i == _step) {
chips.add(Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
));
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tokens.globalFocus.withValues(alpha: 0.18),
border: Border.all(color: tokens.statusInfo),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
),
);
} else {
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
}
@@ -376,8 +381,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 110, child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
Expanded(child: ClideText('${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground)),
SizedBox(
width: 110,
child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
Expanded(
child: ClideText('${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground),
),
],
),
);
@@ -403,10 +413,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
_optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
],
),
if (hasOther) ...[
const SizedBox(height: 8),
_NoteField(controller: _other[qi], placeholder: 'type your answer…'),
],
if (hasOther) ...[const SizedBox(height: 8), _NoteField(controller: _other[qi], placeholder: 'type your answer…')],
const SizedBox(height: 8),
_NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'),
],
@@ -499,10 +506,7 @@ Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic>
/// / timeout annotations.
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final cmd = (input['command'] as String? ?? '').trimRight();
final notes = <String>[
if (input['run_in_background'] == true) 'background',
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
];
final notes = <String>[if (input['run_in_background'] == true) 'background', if (input['timeout'] is num) 'timeout ${input['timeout']}ms'];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -564,19 +568,14 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
}
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
return ClideText(
label.isNotEmpty ? label : toolName,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
);
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
}
/// A muted file path line, shared across tool bodies.
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
);
// -- shared note / free-text field -------------------------------------------
@@ -613,7 +612,7 @@ class _NoteFieldState extends State<_NoteField> {
children: [
ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller,
builder: (_, v, __) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
builder: (_, v, _) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
),
EditableText(
controller: widget.controller,
@@ -652,14 +651,9 @@ List<_Question> _parseQuestions(Map<String, dynamic> input) {
return [
for (final q in raw)
if (q is Map)
_Question(
q['question'] as String? ?? '',
q['header'] as String? ?? '',
q['multiSelect'] as bool? ?? false,
[
for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
],
),
_Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
]),
];
}
+11 -19
View File
@@ -13,13 +13,7 @@ import 'dart:io';
/// One session in the workspace, summarised for the picker.
class SessionSummary {
const SessionSummary({
required this.id,
required this.modified,
this.firstUser,
this.lastUser,
this.sizeBytes = 0,
});
const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
final String id;
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
/// capped at [max]. Each is summarised by bookend user prompts read from a
/// bounded [window] at each end of its transcript.
Future<List<SessionSummary>> listSessions(
Directory dir, {
int max = 20,
int window = 128 * 1024,
}) async {
Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
if (!await dir.exists()) return const [];
final files = <File>[];
await for (final e in dir.list(followLinks: false)) {
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
final stat = await f.stat();
final bookends = await _bookends(f, window);
final id = _sessionId(f.path);
summaries.add(SessionSummary(
id: id,
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
));
summaries.add(
SessionSummary(
id: id,
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
),
);
}
summaries.sort((a, b) => b.modified.compareTo(a.modified));
return summaries.length > max ? summaries.sublist(0, max) : summaries;
@@ -32,11 +32,7 @@ const _resumeTailBytes = 256 * 1024;
/// Creates the subprocess for a session — production uses
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
typedef ProcessFactory = Future<StreamJsonProcess> Function({
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
});
typedef ProcessFactory = Future<StreamJsonProcess> Function({required List<String> sessionArgs, required String cwd, Map<String, String>? env});
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
/// `teammate:tyre`); [sessionId] is claude's `--session-id`.
@@ -150,10 +146,7 @@ ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel(
broker: broker,
sessionResolver: (name) => byMemberName(name)?.session,
);
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
}
final ProcessFactory _factory;
@@ -181,9 +174,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// Just the sessions a pane should currently render.
List<ManagedSession> get visibleSessions => [
for (final m in _sessions.values)
if (m.visible) m,
];
for (final m in _sessions.values)
if (m.visible) m,
];
ManagedSession? byId(String id) => _sessions[id];
@@ -224,18 +217,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
preambles.add(_teamSystemPrompt(name, spec.role));
}
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
sessionArgs = [
'--append-system-prompt',
preambles.join('\n\n'),
...bootstrap.extraArgs,
...sessionArgs,
];
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
final proc = await _factory(
sessionArgs: sessionArgs,
cwd: spec.cwd,
env: bootstrap.envDelta,
);
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose);
@@ -362,7 +346,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
}
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
static String _teamSystemPrompt(String name, String role) =>
'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
'Coordinate with teammates using the clide-team MCP tools: '
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, '
'list_teammates() to see the roster, inbox() to read messages sent to you, and '
+3 -18
View File
@@ -12,12 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class SessionPickerDialog extends StatefulWidget {
const SessionPickerDialog({
super.key,
required this.sessions,
required this.onPick,
required this.onCancel,
});
const SessionPickerDialog({super.key, required this.sessions, required this.onPick, required this.onCancel});
final List<SessionSummary> sessions;
final void Function(String id) onPick;
@@ -92,11 +87,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.sessions.length,
itemBuilder: (ctx, i) => _row(theme, i),
),
child: ListView.builder(shrinkWrap: true, itemCount: widget.sessions.length, itemBuilder: (ctx, i) => _row(theme, i)),
),
],
),
@@ -117,13 +108,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
s.label,
fontSize: clideFontSmall,
color: theme.globalForeground,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall),
],
+4 -22
View File
@@ -17,13 +17,7 @@ import 'package:flutter/widgets.dart';
typedef SessionDeleter = Future<void> Function(Directory dir, String id);
class SessionStorageDialog extends StatefulWidget {
const SessionStorageDialog({
super.key,
required this.dir,
required this.sessions,
required this.onClose,
this.deleter = deleteSession,
});
const SessionStorageDialog({super.key, required this.dir, required this.sessions, required this.onClose, this.deleter = deleteSession});
final Directory dir;
final List<SessionSummary> sessions;
@@ -79,19 +73,11 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: ClideText(
'Session storage · ${formatBytes(_total)} total',
fontSize: clideFontBody,
color: theme.globalForeground,
),
child: ClideText('Session storage · ${formatBytes(_total)} total', fontSize: clideFontBody, color: theme.globalForeground),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ClideText(
'Deleting a session you are currently using will break that pane.',
muted: true,
fontSize: clideFontSmall,
),
child: ClideText('Deleting a session you are currently using will break that pane.', muted: true, fontSize: clideFontSmall),
),
if (_sessions.isEmpty)
Padding(
@@ -100,11 +86,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _sessions.length,
itemBuilder: (ctx, i) => _row(theme, _sessions[i]),
),
child: ListView.builder(shrinkWrap: true, itemCount: _sessions.length, itemBuilder: (ctx, i) => _row(theme, _sessions[i])),
),
],
),
+70 -63
View File
@@ -40,11 +40,7 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
static Future<ClaudeStreamJsonProcess> start({
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
}) async {
static Future<ClaudeStreamJsonProcess> start({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) async {
final proc = await Process.start(
'claude',
[
@@ -176,10 +172,10 @@ final class AllowTool extends ToolDecision {
final String? followUpNote;
@override
Map<String, dynamic> toJson() => {
'behavior': 'allow',
'updatedInput': updatedInput,
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
};
'behavior': 'allow',
'updatedInput': updatedInput,
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
};
}
/// Deny the tool with a user-facing [message] (required by the protocol).
@@ -310,15 +306,17 @@ class StreamJsonSession {
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
// when we actually host a server, so a plain session is unchanged.
if (_mcpServers.isNotEmpty) {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}),
);
}
}
@@ -448,15 +446,17 @@ class StreamJsonSession {
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final tuid = request['tool_use_id'] as String? ?? '';
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
_queue.add(ToolPrompt(
promptId: rid,
toolName: toolName,
displayName: request['display_name'] as String? ?? toolName,
description: request['description'] as String?,
toolUseId: request['tool_use_id'] as String? ?? '',
input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
));
_queue.add(
ToolPrompt(
promptId: rid,
toolName: toolName,
displayName: request['display_name'] as String? ?? toolName,
description: request['description'] as String?,
toolUseId: request['tool_use_id'] as String? ?? '',
input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
),
);
_pendingCtl.add(pendingPrompt);
return; // awaits resolvePrompt
}
@@ -465,10 +465,12 @@ class StreamJsonSession {
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
return;
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}),
);
}
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
@@ -489,14 +491,16 @@ class StreamJsonSession {
} else {
mcpResponse = await _dispatchMcp(server, message);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse}
},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse},
},
}),
);
}
McpServer? _mcpServerNamed(String? name) {
@@ -559,10 +563,12 @@ class StreamJsonSession {
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
}),
);
if (decision is AllowTool) {
// The prompt card is ephemeral (it vanishes once resolved), so leave a
// compact record of an answered question in the conversation log (D-78).
@@ -652,16 +658,13 @@ class StreamJsonSession {
/// so it renders immediately (stream-json doesn't replay stdin without
/// `--replay-user-messages`).
void send(String text) {
_proc.writeLine(jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
}));
_items.add(UserMessage(
uuid: 'local-${_localSeq++}',
timestamp: DateTime.now(),
isSidechain: false,
text: text,
));
_proc.writeLine(
jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
}),
);
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
_setBusy(true);
}
@@ -669,11 +672,13 @@ class StreamJsonSession {
/// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle.
void interrupt() {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'},
}),
);
}
/// Set the session's permission mode (T-181, D-77). Sends a
@@ -685,11 +690,13 @@ class StreamJsonSession {
/// cockpit badge's plain click; bypassPermissions is reachable only via a
/// confirmed shift-click (T-181).
void setPermissionMode(String mode) {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'set-perm-${_localSeq++}',
'request': {'subtype': 'set_permission_mode', 'mode': mode},
}));
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'set-perm-${_localSeq++}',
'request': {'subtype': 'set_permission_mode', 'mode': mode},
}),
);
// Optimistically reflect the change so the badge / status line update
// immediately (T-250) — the control_request emits no status event, and a
// fresh system/init only arrives later. The next init reconciles if the
+4 -4
View File
@@ -47,7 +47,7 @@ List<TaskItem> taskListFrom(List<ConversationItem> items) {
}
TaskStatus _statusFrom(Object? raw) => switch (raw) {
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
+16 -32
View File
@@ -35,13 +35,7 @@ class TeamMemberRef {
/// A message left for a member, in arrival order.
class TeamMessage {
const TeamMessage({
required this.from,
required this.text,
required this.at,
this.to,
this.broadcast = false,
});
const TeamMessage({required this.from, required this.text, required this.at, this.to, this.broadcast = false});
final String from;
/// Recipient name: a single member's display name (direct message), `null`
@@ -52,13 +46,7 @@ class TeamMessage {
final DateTime at;
final bool broadcast;
Map<String, dynamic> toJson() => {
'from': from,
if (to != null) 'to': to,
'text': text,
'at': at.toIso8601String(),
if (broadcast) 'broadcast': true,
};
Map<String, dynamic> toJson() => {'from': from, if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true};
}
/// A shared task. Status is one of `open` / `claimed` / `done`.
@@ -69,12 +57,7 @@ class TeamTask {
String status;
String? owner;
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'status': status,
if (owner != null) 'owner': owner,
};
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'status': status, if (owner != null) 'owner': owner};
}
/// Pushes [text] into the member identified by [toMemberId] as a user message
@@ -304,7 +287,7 @@ class TeamBroker {
return {'ok': true, 'task': t.toJson()};
}
return {
'tasks': [for (final t in _tasks.values) t.toJson()]
'tasks': [for (final t in _tasks.values) t.toJson()],
};
}
@@ -362,25 +345,26 @@ class TeamMcpServer implements McpServer {
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
case 'task_status':
return _result(
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
);
default:
return _error('Unknown team tool: $name');
}
}
Map<String, dynamic> _result(Map<String, dynamic> value) => {
'content': [
{'type': 'text', 'text': jsonEncode(value)},
],
'isError': value['ok'] == false,
};
'content': [
{'type': 'text', 'text': jsonEncode(value)},
],
'isError': value['ok'] == false,
};
Map<String, dynamic> _error(String message) => {
'content': [
{'type': 'text', 'text': message},
],
'isError': true,
};
'content': [
{'type': 'text', 'text': message},
],
'isError': true,
};
}
const _toolDefs = <Map<String, dynamic>>[
+1 -5
View File
@@ -29,11 +29,7 @@ typedef SessionResolver = StreamJsonSession? Function(String memberName);
/// Lifetime matches the orchestrator: created once, subscribed to the broker,
/// disposed when the orchestrator is torn down.
class TeamChatModel {
TeamChatModel({
required TeamBroker broker,
SessionResolver? sessionResolver,
}) : _broker = broker,
_sessionResolver = sessionResolver {
TeamChatModel({required TeamBroker broker, SessionResolver? sessionResolver}) : _broker = broker, _sessionResolver = sessionResolver {
_sub = broker.messages.listen(_onMessage);
}
+16 -78
View File
@@ -26,12 +26,7 @@ import 'package:flutter/widgets.dart';
/// [onPopOut] is called when the user taps the pop-out icon to open the full
/// pane — the extension wires this to `panels.activateTab`.
class TeamChatSidebar extends StatefulWidget {
const TeamChatSidebar({
super.key,
required this.model,
required this.broker,
required this.onPopOut,
});
const TeamChatSidebar({super.key, required this.model, required this.broker, required this.onPopOut});
final TeamChatModel model;
final TeamBroker broker;
@@ -149,11 +144,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
onTap: widget.onPopOut,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: ClideIcon(
PhosphorIcons.byName('arrows-out-simple'),
size: 10,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
@@ -177,13 +168,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: '@name or @team …',
),
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
),
),
],
@@ -200,11 +185,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
/// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the
/// interrupt tickbox and full @-completion.
class TeamChatPane extends StatefulWidget {
const TeamChatPane({
super.key,
required this.model,
required this.broker,
});
const TeamChatPane({super.key, required this.model, required this.broker});
final TeamChatModel model;
final TeamBroker broker;
@@ -231,11 +212,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
// Scroll to bottom on new message.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
);
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut);
}
});
}
@@ -297,11 +274,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
final text = raw.trim();
if (text.isEmpty) return;
final parsed = parseAtTag(text);
widget.model.postAsUser(
parsed.body.isEmpty ? text : parsed.body,
toName: parsed.recipient,
interrupt: _interrupt,
);
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient, interrupt: _interrupt);
_controller.clear();
}
@@ -340,11 +313,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length,
itemBuilder: (_, i) => _ChatRow(
key: ValueKey(messages[i].at.microsecondsSinceEpoch),
message: messages[i],
tokens: tokens,
),
itemBuilder: (_, i) => _ChatRow(key: ValueKey(messages[i].at.microsecondsSinceEpoch), message: messages[i], tokens: tokens),
),
),
// Composer + interrupt tickbox.
@@ -375,24 +344,13 @@ class _TeamChatPaneState extends State<TeamChatPane> {
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000),
border: Border.all(
color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted,
width: 1,
),
border: Border.all(color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, width: 1),
borderRadius: BorderRadius.circular(2),
),
child: _interrupt
? Center(
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
)
: null,
child: _interrupt ? Center(child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus)) : null,
),
),
ClideText(
'Interrupt',
fontSize: clideFontSmall,
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
),
ClideText('Interrupt', fontSize: clideFontSmall, color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted),
],
),
),
@@ -405,13 +363,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
formatLabel: (n) => '@$n',
child: Focus(
onKeyEvent: _handleKeyEvent,
child: _ChatInputField(
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: '@name or @team …',
),
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
),
),
],
@@ -439,8 +391,8 @@ class _ChatRow extends StatelessWidget {
final toLabel = message.broadcast
? '→ all'
: message.to != null
? '${message.to}'
: null;
? '${message.to}'
: null;
return Padding(
padding: const EdgeInsets.only(bottom: 3),
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
margin: const EdgeInsets.only(right: 5, top: 1),
decoration: BoxDecoration(
color: senderColor.withAlpha(30),
borderRadius: BorderRadius.circular(2),
),
decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
),
if (toLabel != null)
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
/// Inline text input for the chat composer.
class _ChatInputField extends StatelessWidget {
const _ChatInputField({
required this.controller,
required this.focusNode,
required this.tokens,
required this.onSubmit,
required this.placeholder,
});
const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
final TextEditingController controller;
final FocusNode focusNode;
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
child: EditableText(
controller: controller,
focusNode: focusNode,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit,
+3 -14
View File
@@ -51,10 +51,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
void _onJoined(TeamMemberJoined m) {
if (_controllers.containsKey(m.agentId)) return;
final kernel = ClideKernel.of(context);
_controllers[m.agentId] = ConversationController.fromBus(
messages: kernel.messages,
channel: ClaudeConversation.teammateChannel(m.agentId),
);
_controllers[m.agentId] = ConversationController.fromBus(messages: kernel.messages, channel: ClaudeConversation.teammateChannel(m.agentId));
setState(() => _members.add(m));
}
@@ -92,11 +89,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
}),
),
Expanded(
child: _TeammateGrid(
members: _members,
controllers: _controllers,
tokens: tokens,
),
child: _TeammateGrid(members: _members, controllers: _controllers, tokens: tokens),
),
],
);
@@ -145,11 +138,7 @@ class _TeammateGrid extends StatelessWidget {
children: [
for (var r = 0; r < rows; r++)
Expanded(
child: Row(
children: [
for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)),
],
),
child: Row(children: [for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c))]),
),
],
),
+7 -4
View File
@@ -36,10 +36,13 @@ Future<bool> applyTicketPickUp(
final id = data['id'] as String?;
final status = data['status'] as String?;
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) {
final resp = await ipc.request('pql.tickets.status', args: {
'ids': [id],
'status': 'in_progress',
});
final resp = await ipc.request(
'pql.tickets.status',
args: {
'ids': [id],
'status': 'in_progress',
},
);
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
}
return true;
@@ -38,11 +38,11 @@ abstract final class ClaudeConversation {
/// Encode [status] for [agentId] as a [memberStatusChannel] message body.
static Map<String, Object?> memberStatusData(String agentId, SessionStatus status) => {
'agentId': agentId,
if (status.model != null) 'model': status.model,
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
};
'agentId': agentId,
if (status.model != null) 'model': status.model,
if (status.permissionMode != null) 'permissionMode': status.permissionMode,
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
};
}
class TranscriptPublisher {
@@ -50,16 +50,11 @@ class TranscriptPublisher {
/// [ClaudeConversation.publisher] / [channel]. The subscription is
/// attached synchronously, so a controller that subscribes before the
/// reader's first poll never misses the initial tail.
TranscriptPublisher({
required MessageBus messages,
required TranscriptReader reader,
this.channel = ClaudeConversation.leadChannel,
}) : _messages = messages,
_reader = reader {
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
: _messages = messages,
_reader = reader {
_sub = _reader.stream.listen((item) {
_messages.publish(ClaudeConversation.publisher, channel, {
ClaudeConversation.itemKey: item,
});
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
});
}
+75 -70
View File
@@ -171,13 +171,7 @@ final class AssistantToolUse extends ConversationItem {
/// driver has already resolved (workspace-relative paths are resolved before
/// injection); [caption] is an optional one-line label.
final class ImageMessage extends ConversationItem {
const ImageMessage({
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.path,
this.caption,
});
const ImageMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.path, this.caption});
/// Absolute path to the image file on disk.
final String path;
@@ -215,14 +209,7 @@ const _isolateParseThreshold = 64 * 1024;
const _knownMajorVersions = {1, 2};
/// Record types to skip (do not emit as conversation items).
const _skipTypes = {
'attachment',
'system',
'last-prompt',
'permission-mode',
'file-history-snapshot',
'queue-operation',
};
const _skipTypes = {'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'};
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s.
///
@@ -242,11 +229,11 @@ class TranscriptReader {
String? projectsBase,
int? initialTailBytes,
String? file,
}) : _pollInterval = pollInterval,
_onWarn = onWarn ?? _defaultWarn,
_projectsBase = projectsBase ?? _defaultProjectsBase(),
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
_explicitFile = file;
}) : _pollInterval = pollInterval,
_onWarn = onWarn ?? _defaultWarn,
_projectsBase = projectsBase ?? _defaultProjectsBase(),
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
_explicitFile = file;
final String workspacePath;
final Duration _pollInterval;
@@ -439,14 +426,7 @@ class TranscriptReader {
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status.
class SessionStatus {
const SessionStatus({
this.model,
this.permissionMode,
this.contextTokens,
this.cost,
this.contextWindow,
this.rateLimitInfo,
});
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model;
@@ -475,13 +455,13 @@ class SessionStatus {
/// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus(
model: other.model ?? model,
permissionMode: other.permissionMode ?? permissionMode,
contextTokens: other.contextTokens ?? contextTokens,
cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
);
model: other.model ?? model,
permissionMode: other.permissionMode ?? permissionMode,
contextTokens: other.contextTokens ?? contextTokens,
cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
);
@override
bool operator ==(Object other) =>
@@ -509,13 +489,13 @@ class _StatusAcc {
int? contextWindow;
String? rateLimitInfo;
SessionStatus toStatus() => SessionStatus(
model: model,
permissionMode: permissionMode,
contextTokens: contextTokens,
cost: cost,
contextWindow: contextWindow,
rateLimitInfo: rateLimitInfo,
);
model: model,
permissionMode: permissionMode,
contextTokens: contextTokens,
cost: cost,
contextWindow: contextWindow,
rateLimitInfo: rateLimitInfo,
);
}
// ---------------------------------------------------------------------------
@@ -550,8 +530,10 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
final major = int.tryParse(majorStr);
if (major != null && !_knownMajorVersions.contains(major)) {
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); '
'parsing will degrade gracefully');
warnings.add(
'unfamiliar transcript version "$rawVersion" (major=$major); '
'parsing will degrade gracefully',
);
}
}
@@ -628,14 +610,17 @@ void _parseUserInto(
if (content is String) {
if (content.isNotEmpty) {
out.add(UserMessage(
out.add(
UserMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: content,
injected: injected));
injected: injected,
),
);
}
return;
}
@@ -650,16 +635,18 @@ void _parseUserInto(
if (text.isNotEmpty) textParts.add(text);
case 'tool_result':
final rawContent = item['content'];
out.add(ToolResultMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
));
out.add(
ToolResultMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
),
);
default:
break;
}
@@ -689,27 +676,45 @@ void _parseAssistantInto(
case 'text':
final text = item['text'] as String? ?? '';
if (text.isNotEmpty) {
out.add(AssistantTextMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, text: text));
out.add(
AssistantTextMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: text,
),
);
}
case 'thinking':
final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, thinking: thinking));
out.add(
AssistantThinkingMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
thinking: thinking,
),
);
}
case 'tool_use':
final rawInput = item['input'];
out.add(AssistantToolUse(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
));
out.add(
AssistantToolUse(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
),
);
default:
break;
}
+19 -26
View File
@@ -61,30 +61,23 @@ class CliInstallExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'clide.installCli',
command: 'clide.installCli',
title: "clide: Install 'clide' command in PATH",
run: (_) async {
final r = _resolved.install();
final ctx = _ctx;
if (r.ok) {
ctx?.notify.success(r.message, title: 'clide CLI installed');
return IpcResponse.ok(id: '', data: {
'installed': r.installedPath,
'onPath': r.onPath,
});
}
ctx?.notify.error(r.message, title: 'clide CLI install failed');
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: r.message,
),
);
},
),
];
CommandContribution(
id: 'clide.installCli',
command: 'clide.installCli',
title: "clide: Install 'clide' command in PATH",
run: (_) async {
final r = _resolved.install();
final ctx = _ctx;
if (r.ok) {
ctx?.notify.success(r.message, title: 'clide CLI installed');
return IpcResponse.ok(id: '', data: {'installed': r.installedPath, 'onPath': r.onPath});
}
ctx?.notify.error(r.message, title: 'clide CLI install failed');
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
);
},
),
];
}
+8 -20
View File
@@ -1,34 +1,22 @@
import 'dart:ui' show Color;
class DecisionTypeColors {
const DecisionTypeColors({
required this.confirmed,
required this.question,
required this.rejected,
});
const DecisionTypeColors({required this.confirmed, required this.question, required this.rejected});
final Color confirmed;
final Color question;
final Color rejected;
Color forType(String? type) => switch (type) {
'confirmed' => confirmed,
'question' => question,
'rejected' => rejected,
_ => confirmed,
};
'confirmed' => confirmed,
'question' => question,
'rejected' => rejected,
_ => confirmed,
};
static const dark = DecisionTypeColors(
confirmed: Color(0xFF7DD3A8),
question: Color(0xFFE6C370),
rejected: Color(0xFFE87D7D),
);
static const dark = DecisionTypeColors(confirmed: Color(0xFF7DD3A8), question: Color(0xFFE6C370), rejected: Color(0xFFE87D7D));
static const light = DecisionTypeColors(
confirmed: Color(0xFF1D7A4E),
question: Color(0xFFB08A20),
rejected: Color(0xFFC03030),
);
static const light = DecisionTypeColors(confirmed: Color(0xFF1D7A4E), question: Color(0xFFB08A20), rejected: Color(0xFFC03030));
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
}
@@ -110,10 +110,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
return ClidePaneChrome(
title: id,
subtitle: title,
leading: ReaderPinButton(
pinned: _nav?.hasPinned ?? false,
onTap: _decision != null ? _onPin : null,
),
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _decision != null ? _onPin : null),
trailing: [
ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false,
@@ -144,7 +141,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
children: [
ClideTooltip(
message: type ?? 'confirmed',
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
),
),
const SizedBox(width: 8),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
@@ -159,21 +160,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
),
const SizedBox(height: 8),
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[
const SizedBox(height: 6),
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
],
if (status != null && status != 'active') ...[
const SizedBox(height: 8),
_StatusBadge(status: status, tokens: tokens),
],
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
],
),
),
if (body != null && body.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
],
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
+55 -20
View File
@@ -139,12 +139,14 @@ class _DecisionsViewState extends State<DecisionsView> {
final hasFilter = lf.isNotEmpty;
final filtered = hasFilter
? _decisions
.where((d) =>
d.id.toLowerCase().contains(lf) ||
d.title.toLowerCase().contains(lf) ||
(d.domain ?? '').toLowerCase().contains(lf) ||
(d.type ?? '').contains(lf))
.toList()
.where(
(d) =>
d.id.toLowerCase().contains(lf) ||
d.title.toLowerCase().contains(lf) ||
(d.domain ?? '').toLowerCase().contains(lf) ||
(d.type ?? '').contains(lf),
)
.toList()
: _decisions;
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
@@ -158,7 +160,9 @@ class _DecisionsViewState extends State<DecisionsView> {
children: [
Row(
children: [
Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
Expanded(
child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
@@ -180,39 +184,66 @@ class _DecisionsViewState extends State<DecisionsView> {
ClideAccordion(
label: 'CONFIRMED',
count: confirmed.length,
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle),
),
expanded: hasFilter || _isSectionExpanded('confirmed'),
onToggle: () => _toggleSection('confirmed'),
children: [
for (final d in confirmed)
_DecisionCard(
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
entry: d,
tokens: tokens,
typeColors: typeColors,
focused: d.id == _focusedId,
focusKey: d.id == _focusedId ? _focusedKey : null,
),
],
),
if (questions.isNotEmpty)
ClideAccordion(
label: 'QUESTIONS',
count: questions.length,
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle),
),
expanded: hasFilter || _isSectionExpanded('question'),
onToggle: () => _toggleSection('question'),
children: [
for (final d in questions)
_DecisionCard(
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
entry: d,
tokens: tokens,
typeColors: typeColors,
focused: d.id == _focusedId,
focusKey: d.id == _focusedId ? _focusedKey : null,
),
],
),
if (rejected.isNotEmpty)
ClideAccordion(
label: 'REJECTED',
count: rejected.length,
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle),
),
expanded: hasFilter || _isSectionExpanded('rejected'),
onToggle: () => _toggleSection('rejected'),
children: [
for (final d in rejected)
_DecisionCard(
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
entry: d,
tokens: tokens,
typeColors: typeColors,
focused: d.id == _focusedId,
focusKey: d.id == _focusedId ? _focusedKey : null,
),
],
),
],
@@ -233,12 +264,12 @@ class _DecisionEntry {
final String? status;
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String?,
domain: json['domain'] as String?,
status: json['status'] as String?,
);
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String?,
domain: json['domain'] as String?,
status: json['status'] as String?,
);
}
class _DecisionCard extends StatelessWidget {
@@ -271,7 +302,11 @@ class _DecisionCard extends StatelessWidget {
children: [
ClideTooltip(
message: entry.type ?? 'confirmed',
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
),
),
const SizedBox(width: 6),
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
+17 -17
View File
@@ -37,21 +37,21 @@ class DecisionsExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'decisions.panel',
slot: Slots.sidebar,
title: 'Decisions',
titleKey: 'tab.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionsView(),
),
TabContribution(
id: 'decisions.detail',
slot: Slots.contextPanel,
title: 'Decision',
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionDetailView(),
),
];
TabContribution(
id: 'decisions.panel',
slot: Slots.sidebar,
title: 'Decisions',
titleKey: 'tab.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionsView(),
),
TabContribution(
id: 'decisions.detail',
slot: Slots.contextPanel,
title: 'Decision',
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionDetailView(),
),
];
}
+3 -3
View File
@@ -23,9 +23,9 @@ class DeepLinkAction {
/// A human-readable description for the confirmation prompt.
String get describe => switch (name) {
'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
_ => name,
};
'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
_ => name,
};
}
/// Parse [url] into a [DeepLinkAction], or null when it is malformed, not a
+2 -7
View File
@@ -25,13 +25,8 @@ class DeepLinkExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'deeplink.invoke',
command: 'deeplink.invoke',
title: 'Open a clide:// deep link',
run: _invoke,
),
];
CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke),
];
@override
Future<void> activate(ClideExtensionContext ctx) async => _ctx = ctx;
+51 -102
View File
@@ -15,101 +15,54 @@ class DefaultLayoutExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
_preset ?? classicPreset(),
CommandContribution(
id: 'layout.reset',
command: 'layout.reset',
title: 'Layout: Reset to Classic',
run: _reset,
),
CommandContribution(
id: 'palette.toggle',
command: 'palette.toggle',
title: 'Command Palette',
defaultBinding: 'ctrl+shift+p',
run: _togglePalette,
),
// Collapse toggles (D-051, D-054)
CommandContribution(
id: 'sidebar.collapse',
command: 'sidebar.collapse',
title: 'Toggle Sidebar Collapse',
defaultBinding: 'ctrl+shift+1',
run: _collapseSidebar,
),
CommandContribution(
id: 'context.collapse',
command: 'context.collapse',
title: 'Toggle Context Panel Collapse',
defaultBinding: 'ctrl+shift+3',
run: _collapseContext,
),
// Panel focus (D-054)
CommandContribution(
id: 'panel.focus.left',
command: 'panel.focus.left',
title: 'Focus Left Panel',
defaultBinding: 'ctrl+1',
run: _focusLeft,
),
CommandContribution(
id: 'panel.focus.middle',
command: 'panel.focus.middle',
title: 'Focus Middle Panel',
defaultBinding: 'ctrl+2',
run: _focusMiddle,
),
CommandContribution(
id: 'panel.focus.right',
command: 'panel.focus.right',
title: 'Focus Right Panel',
defaultBinding: 'ctrl+3',
run: _focusRight,
),
// Focus mode (D-052, D-054)
CommandContribution(
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution(
id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit',
title: 'Exit Focus Mode',
defaultBinding: 'escape',
// Stand down in Vim insert/visual mode so Esc returns to normal
// mode instead of closing the editor (T-257). Symmetric with
// vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
bindingWhen: '!vim.insert && !vim.visual',
run: _exitFocusMode,
),
// Editor split (D-049, D-054)
CommandContribution(
id: 'editor.open',
command: 'editor.open',
title: 'Open Editor',
defaultBinding: 'ctrl+e',
run: _openEditor,
),
CommandContribution(
id: 'editor.close',
command: 'editor.close',
title: 'Close Editor',
defaultBinding: 'ctrl+w',
run: _closeEditor,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
id: 'sidebar.section.${i + 1}',
command: 'sidebar.section.${i + 1}',
title: 'Sidebar: Section ${i + 1}',
defaultBinding: 'alt+${i + 1}',
run: (args) => _switchSidebarSection(i),
),
];
_preset ?? classicPreset(),
CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset),
CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette),
// Collapse toggles (D-051, D-054)
CommandContribution(
id: 'sidebar.collapse',
command: 'sidebar.collapse',
title: 'Toggle Sidebar Collapse',
defaultBinding: 'ctrl+shift+1',
run: _collapseSidebar,
),
CommandContribution(
id: 'context.collapse',
command: 'context.collapse',
title: 'Toggle Context Panel Collapse',
defaultBinding: 'ctrl+shift+3',
run: _collapseContext,
),
// Panel focus (D-054)
CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
// Focus mode (D-052, D-054)
CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
CommandContribution(
id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit',
title: 'Exit Focus Mode',
defaultBinding: 'escape',
// Stand down in Vim insert/visual mode so Esc returns to normal
// mode instead of closing the editor (T-257). Symmetric with
// vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
bindingWhen: '!vim.insert && !vim.visual',
run: _exitFocusMode,
),
// Editor split (D-049, D-054)
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
id: 'sidebar.section.${i + 1}',
command: 'sidebar.section.${i + 1}',
title: 'Sidebar: Section ${i + 1}',
defaultBinding: 'alt+${i + 1}',
run: (args) => _switchSidebarSection(i),
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
@@ -313,11 +266,7 @@ class DefaultLayoutExtension extends ClideExtension {
}
static IpcResponse _notActivated() => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'not activated',
),
);
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'not activated'),
);
}
+2 -8
View File
@@ -50,18 +50,12 @@ class DiffController extends ChangeNotifier {
}
/// Load diffs. Optionally filter to [paths] and toggle [staged].
Future<void> load({
bool staged = false,
List<String> paths = const [],
}) async {
Future<void> load({bool staged = false, List<String> paths = const []}) async {
_staged = staged;
_loading = true;
notifyListeners();
final r = await ipc.request('git.diff', args: {
'staged': staged,
if (paths.isNotEmpty) 'paths': paths,
});
final r = await ipc.request('git.diff', args: {'staged': staged, if (paths.isNotEmpty) 'paths': paths});
_loading = false;
if (!r.ok) {
+15 -78
View File
@@ -98,24 +98,11 @@ class _DiffViewState extends State<DiffView> {
if (c.error != null)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
c.error!,
color: tokens.statusError,
),
),
if (c.loading && c.diffs.isEmpty)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
child: ClideText(c.error!, color: tokens.statusError),
),
if (c.loading && c.diffs.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.diffs.isEmpty && c.error == null)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
muted: true,
),
),
Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
Expanded(
child: SingleChildScrollView(
controller: _scroll,
@@ -163,11 +150,7 @@ class _DiffToolbar extends StatelessWidget {
label: 'show unstaged changes',
child: GestureDetector(
onTap: controller.showStaged ? controller.toggleStaged : null,
child: ClideText(
'Unstaged',
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
),
child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
),
),
const SizedBox(width: 12),
@@ -177,11 +160,7 @@ class _DiffToolbar extends StatelessWidget {
label: 'show staged changes',
child: GestureDetector(
onTap: controller.showStaged ? null : controller.toggleStaged,
child: ClideText(
'Staged',
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
),
child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
),
),
],
@@ -233,11 +212,7 @@ class _FileDiff extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
path,
fontSize: clideFontCaption,
color: focused ? tokens.globalFocus : tokens.panelHeaderForeground,
),
child: ClideText(path, fontSize: clideFontCaption, color: focused ? tokens.globalFocus : tokens.panelHeaderForeground),
),
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
@@ -250,12 +225,7 @@ class _FileDiff extends StatelessWidget {
child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
),
if (!isBinary)
for (final hunk in hunks)
_HunkView(
hunk: (hunk as Map).cast<String, Object?>(),
filePath: path,
controller: controller,
),
for (final hunk in hunks) _HunkView(hunk: (hunk as Map).cast<String, Object?>(), filePath: path, controller: controller),
const SizedBox(height: 8),
],
);
@@ -263,11 +233,7 @@ class _FileDiff extends StatelessWidget {
}
class _HunkView extends StatelessWidget {
const _HunkView({
required this.hunk,
required this.filePath,
required this.controller,
});
const _HunkView({required this.hunk, required this.filePath, required this.controller});
final Map<String, Object?> hunk;
final String filePath;
@@ -284,17 +250,9 @@ class _HunkView extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: ClideText(
header,
fontSize: clideFontMono,
muted: true,
fontFamily: clideMonoFamily,
),
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
),
for (final lineObj in lines)
_DiffLineRow(
line: (lineObj as Map).cast<String, Object?>(),
),
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
],
);
}
@@ -313,18 +271,9 @@ class _DiffLineRow extends StatelessWidget {
final newLineNo = line['newLineNo'] as num?;
final (Color bg, Color fg) = switch (kind) {
'addition' => (
tokens.statusSuccess.withValues(alpha: 0.15),
tokens.statusSuccess,
),
'removal' => (
tokens.statusError.withValues(alpha: 0.15),
tokens.statusError,
),
_ => (
const Color(0x00000000),
tokens.globalForeground,
),
'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
_ => (const Color(0x00000000), tokens.globalForeground),
};
final prefix = switch (kind) {
@@ -361,22 +310,10 @@ class _DiffLineRow extends StatelessWidget {
),
),
const SizedBox(width: 4),
ClideText(
prefix,
fontSize: clideFontMono,
color: fg,
fontFamily: clideMonoFamily,
),
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
const SizedBox(width: 2),
Expanded(
child: ClideText(
text,
fontSize: clideFontMono,
color: fg,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.clip,
),
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
),
],
),
+10 -10
View File
@@ -24,16 +24,16 @@ class DiffExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'diff.view',
slot: Slots.workspace,
title: 'Diff',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -70,
build: (_) => DiffView(controller: _controller),
),
];
TabContribution(
id: 'diff.view',
slot: Slots.workspace,
title: 'Diff',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -70,
build: (_) => DiffView(controller: _controller),
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
+3 -15
View File
@@ -99,12 +99,7 @@ class EditorController extends ChangeNotifier {
if (raw is! List) return;
_buffers = [
for (final b in raw)
if (b is Map)
(
id: b['id']! as String,
path: b['path']! as String,
dirty: (b['dirty'] as bool?) ?? false,
),
if (b is Map) (id: b['id']! as String, path: b['path']! as String, dirty: (b['dirty'] as bool?) ?? false),
];
notifyListeners();
}
@@ -143,10 +138,7 @@ class EditorController extends ChangeNotifier {
}
/// Called by the widget on every local text edit.
void pushLocalEdit({
required String newContent,
required Selection newSelection,
}) {
void pushLocalEdit({required String newContent, required Selection newSelection}) {
final id = _activeId;
if (id == null) return;
@@ -162,11 +154,7 @@ class EditorController extends ChangeNotifier {
// large buffers, so event broadcasts stay small.
_pendingLocalEdits++;
_suppressNextRemoteEdit = true;
ipc.request('editor.set-content', args: {
'id': id,
'text': newContent,
'selection': newSelection.toJson(),
}).whenComplete(() => _pendingLocalEdits--);
ipc.request('editor.set-content', args: {'id': id, 'text': newContent, 'selection': newSelection.toJson()}).whenComplete(() => _pendingLocalEdits--);
}
Future<void> save() async {
+14 -23
View File
@@ -65,10 +65,7 @@ class _EditorViewState extends State<EditorView> {
final kernel = ClideKernel.of(context);
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
_keymap = kernel.keymap;
_matcher = SequenceMatcher(
keymap: () => kernel.keymap.keymap ?? Keymap(const []),
context: () => kernel.keymap.scope,
);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
// Rebuild when the Vim mode flips so the editor toggles read-only.
kernel.keymap.addListener(_onModeChanged);
unawaited(_controller!.hydrate());
@@ -105,10 +102,7 @@ class _EditorViewState extends State<EditorView> {
_text.updatePath(c.activePath);
if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content;
final sel = TextSelection(
baseOffset: c.selection.start.clamp(0, c.content.length),
extentOffset: c.selection.end.clamp(0, c.content.length),
);
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
_text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged);
@@ -306,9 +300,7 @@ class _EditorViewState extends State<EditorView> {
return ClidePaneChrome(
title: 'editor',
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
child: const Center(
child: ClideText('Open a file to begin editing.', muted: true),
),
child: const Center(child: ClideText('Open a file to begin editing.', muted: true)),
);
}
return MultitabPane<String>(
@@ -357,12 +349,7 @@ class _TextBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: foreground,
fontSize: clideFontMono,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
);
final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
final editable = EditableText(
controller: controller,
focusNode: focus,
@@ -398,7 +385,10 @@ class _TextBody extends StatelessWidget {
/// Advance width of one monospace glyph in [style].
static double _charWidth(TextStyle style) {
final tp = TextPainter(text: TextSpan(text: '0', style: style), textDirection: TextDirection.ltr)..layout();
final tp = TextPainter(
text: TextSpan(text: '0', style: style),
textDirection: TextDirection.ltr,
)..layout();
return tp.width;
}
}
@@ -413,11 +403,12 @@ class _RulerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
Paint()
..color = color
..strokeWidth = 1);
Offset(x, 0),
Offset(x, size.height),
Paint()
..color = color
..strokeWidth = 1,
);
}
@override
+10 -10
View File
@@ -50,14 +50,14 @@ class EditorExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'editor.active',
slot: Slots.workspace,
title: 'Editor',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 80, // between Claude (90) and welcome (-100)
build: (_) => const EditorView(),
),
];
TabContribution(
id: 'editor.active',
slot: Slots.workspace,
title: 'Editor',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 80, // between Claude (90) and welcome (-100)
build: (_) => const EditorView(),
),
];
}
@@ -36,18 +36,23 @@ class SyntaxTextController extends TextEditingController {
if (source == _highlightedText) return;
_highlighting = true;
_syntax.highlight(path, source).then((result) {
_highlighting = false;
if (text != source) {
_requestHighlight();
return;
}
_highlightedText = source;
_spans = result.spans;
notifyListeners();
}, onError: (_) {
_highlighting = false;
});
_syntax
.highlight(path, source)
.then(
(result) {
_highlighting = false;
if (text != source) {
_requestHighlight();
return;
}
_highlightedText = source;
_spans = result.spans;
notifyListeners();
},
onError: (_) {
_highlighting = false;
},
);
}
@override
@@ -57,11 +62,7 @@ class SyntaxTextController extends TextEditingController {
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
final tokens = _tokens;
if (_spans.isEmpty || tokens == null || text.isEmpty) {
return TextSpan(text: text, style: style);
@@ -125,20 +126,17 @@ class SyntaxTextController extends TextEditingController {
// Gap before this span — plain text.
if (spanCharStart > charPos) {
children.add(TextSpan(
text: source.substring(charPos, spanCharStart),
style: style,
));
children.add(TextSpan(text: source.substring(charPos, spanCharStart), style: style));
}
// The highlighted span.
if (spanCharEnd > spanCharStart) {
children.add(TextSpan(
text: source.substring(spanCharStart, spanCharEnd),
style: style?.copyWith(
color: TreeSitterService.colorForRole(span.role, tokens),
children.add(
TextSpan(
text: source.substring(spanCharStart, spanCharEnd),
style: style?.copyWith(color: TreeSitterService.colorForRole(span.role, tokens)),
),
));
);
}
charPos = spanCharEnd;
@@ -146,10 +144,7 @@ class SyntaxTextController extends TextEditingController {
// Trailing plain text.
if (charPos < source.length) {
children.add(TextSpan(
text: source.substring(charPos),
style: style,
));
children.add(TextSpan(text: source.substring(charPos), style: style));
}
return TextSpan(style: style, children: children);
+72 -26
View File
@@ -79,13 +79,7 @@ class VimResult {
/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual]
/// selects between collapse-to-caret (normal) and extend-from-anchor
/// (visual) for motions, and enables the `visual*` range ops.
VimResult applyVim(
String action,
TextEditingValue v, {
VimRegister register = VimRegister.empty,
bool visual = false,
int count = 1,
}) {
VimResult applyVim(String action, TextEditingValue v, {VimRegister register = VimRegister.empty, bool visual = false, int count = 1}) {
final t = v.text;
final caret = v.selection.extentOffset.clamp(0, t.length);
final anchor = v.selection.baseOffset.clamp(0, t.length);
@@ -226,7 +220,8 @@ int _up(String t, int off) {
int _cls(String ch) {
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0;
final c = ch.codeUnitAt(0);
final isWord = (c >= 0x30 && c <= 0x39) || // 0-9
final isWord =
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z
c == 0x5F; // _
@@ -279,13 +274,19 @@ int _wordEnd(String t, int off) {
// -- Edit helpers -----------------------------------------------------------
VimResult _collapsed(String text, int caret) => VimResult(
TextEditingValue(text: text, selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length))),
);
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length)),
),
);
VimResult _insertAt(String t, int caret) => VimResult(
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length))),
enterInsert: true,
);
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length)),
),
enterInsert: true,
);
VimResult _deleteChar(String t, int caret, int count) {
final le = _lineEnd(t, caret);
@@ -298,7 +299,10 @@ VimResult _deleteChar(String t, int caret, int count) {
final nle = _lineEnd(nt, caret);
final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls);
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ncaret)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ncaret),
),
register: VimRegister(removed),
);
}
@@ -324,7 +328,10 @@ VimResult _deleteLines(String t, int caret, int count) {
caretLineStart = ls;
}
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart))),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart)),
),
register: reg,
);
}
@@ -337,7 +344,10 @@ VimResult _deleteToEnd(String t, int caret) {
final ls = _lineStart(nt, caret);
final nle = _lineEnd(nt, caret);
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls))),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls)),
),
register: VimRegister(removed),
);
}
@@ -349,7 +359,10 @@ VimResult _deleteToOffset(String t, int caret, int target, {bool insert = false}
final removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, '');
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed),
enterInsert: insert,
);
@@ -366,7 +379,10 @@ VimResult _changeLine(String t, int caret, int count) {
final removed = t.substring(ls, end);
final nt = t.replaceRange(ls, end, '');
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ls),
),
register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true),
enterInsert: true,
);
@@ -382,7 +398,10 @@ VimResult _yankLines(String t, int caret, int count) {
}
final yanked = t.substring(ls, end);
return VimResult(
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: caret)),
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: caret),
),
register: VimRegister(yanked.endsWith('\n') ? yanked : '$yanked\n', linewise: true),
);
}
@@ -394,7 +413,12 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
if (before) {
final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, body);
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls))));
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls)),
),
);
}
final le = _lineEnd(t, caret);
final insertAt = le < t.length ? le + 1 : t.length;
@@ -402,12 +426,22 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
final chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}';
final nt = t.replaceRange(insertAt, insertAt, chunk);
final caretLine = le < t.length ? insertAt : insertAt + 1;
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine))));
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine)),
),
);
}
// Charwise: p pastes after the caret, P at the caret.
final at = before ? caret : _clamp(caret + 1, 0, t.length);
final nt = t.replaceRange(at, at, reg.text);
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: at + reg.text.length - 1)));
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: at + reg.text.length - 1),
),
);
}
VimResult _openLine(String t, int caret, {required bool below}) {
@@ -415,14 +449,20 @@ VimResult _openLine(String t, int caret, {required bool below}) {
final le = _lineEnd(t, caret);
final nt = t.replaceRange(le, le, '\n');
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: le + 1)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: le + 1),
),
enterInsert: true,
);
}
final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, '\n');
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ls),
),
enterInsert: true,
);
}
@@ -435,7 +475,10 @@ VimResult _deleteRange(String t, int anchor, int caret, {required bool insert})
final removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, '');
return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed),
enterInsert: insert,
);
@@ -445,7 +488,10 @@ VimResult _yankRange(String t, int anchor, int caret) {
final lo = anchor < caret ? anchor : caret;
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
return VimResult(
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: lo)),
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(t.substring(lo, hi)),
);
}
+11 -11
View File
@@ -19,15 +19,15 @@ class FilesExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'files.tree',
slot: Slots.sidebar,
title: 'Files',
icon: PhosphorIcons.byName('folder'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -100,
build: (_) => const FileTreeView(),
),
];
TabContribution(
id: 'files.tree',
slot: Slots.sidebar,
title: 'Files',
icon: PhosphorIcons.byName('folder'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -100,
build: (_) => const FileTreeView(),
),
];
}
+12 -61
View File
@@ -50,17 +50,11 @@ class _FileTreeViewState extends State<FileTreeView> {
listenable: c,
builder: (context, _) {
if (c.error != null && c.rootPath == null) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(c.error!, muted: true),
);
return Padding(padding: const EdgeInsets.all(12), child: ClideText(c.error!, muted: true));
}
final root = c.rootPath;
if (root == null) {
return const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
);
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
}
final rootName = root.split(Platform.pathSeparator).last;
return Column(
@@ -98,18 +92,12 @@ class _FileTreeViewState extends State<FileTreeView> {
final matches = c.allLoadedEntries().where((e) {
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
}).toList();
return [
for (final e in matches) _FilteredFileRow(entry: e),
];
return [for (final e in matches) _FilteredFileRow(entry: e)];
}
}
class _Children extends StatelessWidget {
const _Children({
required this.path,
required this.controller,
required this.depth,
});
const _Children({required this.path, required this.controller, required this.depth});
final String path;
final FileTreeController controller;
@@ -129,33 +117,19 @@ class _Children extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_DirRow(
name: e.name,
path: e.path,
controller: controller,
depth: depth,
),
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
],
)
else
_FileRow(
name: e.name,
path: e.path,
depth: depth,
),
_FileRow(name: e.name, path: e.path, depth: depth),
],
);
}
}
class _DirRow extends StatelessWidget {
const _DirRow({
required this.name,
required this.path,
required this.controller,
required this.depth,
});
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
final String name;
final String path;
@@ -173,11 +147,7 @@ class _DirRow extends StatelessWidget {
child: _Row(
depth: depth,
onTap: () => controller.toggle(path),
leading: ClideIcon(
const ChevronRightIcon(),
size: 10,
color: tokens.sidebarForeground,
),
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
label: name,
rotateLeading: expanded,
),
@@ -186,11 +156,7 @@ class _DirRow extends StatelessWidget {
}
class _FileRow extends StatelessWidget {
const _FileRow({
required this.name,
required this.path,
required this.depth,
});
const _FileRow({required this.name, required this.path, required this.depth});
final String name;
final String path;
@@ -202,11 +168,7 @@ class _FileRow extends StatelessWidget {
button: true,
label: 'Open $name',
onTap: () => _openFile(context, path),
child: _Row(
depth: depth,
onTap: () => _openFile(context, path),
label: name,
),
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
);
}
@@ -218,13 +180,7 @@ class _FileRow extends StatelessWidget {
}
class _Row extends StatelessWidget {
const _Row({
required this.depth,
required this.onTap,
required this.label,
this.leading,
this.rotateLeading = false,
});
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
final int depth;
final VoidCallback onTap;
@@ -252,12 +208,7 @@ class _Row extends StatelessWidget {
] else
const SizedBox(width: 16),
Expanded(
child: ClideText(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
child: ClideText(label, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
),
],
),
+12 -16
View File
@@ -16,20 +16,16 @@ class GitExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'git.panel',
slot: Slots.sidebar,
title: 'Git',
icon: PhosphorIcons.byName('git-branch'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -80,
build: (_) => const GitPanelView(),
),
StatusItemContribution(
id: 'git.branch',
priority: 10,
build: (_) => const GitStatusItem(),
),
];
TabContribution(
id: 'git.panel',
slot: Slots.sidebar,
title: 'Git',
icon: PhosphorIcons.byName('git-branch'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -80,
build: (_) => const GitPanelView(),
),
StatusItemContribution(id: 'git.branch', priority: 10, build: (_) => const GitStatusItem()),
];
}
+1 -3
View File
@@ -114,9 +114,7 @@ class GitController extends ChangeNotifier {
}
Future<bool> stash({String? message}) async {
final r = await ipc.request('git.stash', args: {
if (message != null) 'message': message,
});
final r = await ipc.request('git.stash', args: {'message': ?message});
return r.ok;
}
+77 -206
View File
@@ -77,90 +77,58 @@ class _GitPanelViewState extends State<GitPanelView> {
children: [
ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_BranchHeader(controller: c),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(
c.error!,
color: tokens.statusError,
fontSize: clideFontCaption,
maxLines: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_BranchHeader(controller: c),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
),
if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.isClean && c.error == null)
const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
entries: _applyFilter(c.staged),
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
],
if (c.unstaged.isNotEmpty)
_FileGroup(
label: 'Changes',
entries: _applyFilter(c.unstaged),
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
if (c.untracked.isNotEmpty)
_FileGroup(
label: 'Untracked',
entries: _applyFilter(c.untracked),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () {
final paths = [for (final e in c.untracked) e['path'] as String];
unawaited(c.stage(paths));
},
),
],
onStage: (path) => unawaited(c.stage([path])),
),
),
if (c.loading && c.isClean)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
if (!c.loading && c.isClean && c.error == null)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Nothing to commit, working tree clean.', muted: true),
),
if (c.conflicted.isNotEmpty)
_FileGroup(
label: 'Merge conflicts',
entries: _applyFilter(c.conflicted),
actions: const [],
),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
entries: _applyFilter(c.staged),
actions: [
_GroupAction(
label: 'Unstage all',
onTap: () => unawaited(c.unstage(const [])),
),
],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(
commitMsg: _commitMsg,
commitFocus: _commitFocus,
controller: c,
),
],
if (c.unstaged.isNotEmpty)
_FileGroup(
label: 'Changes',
entries: _applyFilter(c.unstaged),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () => unawaited(c.stageAll()),
),
],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
if (c.untracked.isNotEmpty)
_FileGroup(
label: 'Untracked',
entries: _applyFilter(c.untracked),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () {
final paths = [
for (final e in c.untracked) e['path'] as String,
];
unawaited(c.stage(paths));
},
),
],
onStage: (path) => unawaited(c.stage([path])),
),
],
),
),
)),
),
],
),
);
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
),
_SmallAction(
label: 'Pull',
semanticsLabel: 'git pull',
onTap: () => unawaited(controller.pull()),
child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
const SizedBox(width: 4),
_SmallAction(
label: 'Push',
semanticsLabel: 'git push',
onTap: () => unawaited(controller.push()),
),
_SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
],
),
);
@@ -209,11 +165,7 @@ class _BranchHeader extends StatelessWidget {
}
class _CommitInput extends StatelessWidget {
const _CommitInput({
required this.commitMsg,
required this.commitFocus,
required this.controller,
});
const _CommitInput({required this.commitMsg, required this.commitFocus, required this.controller});
final TextEditingController commitMsg;
final FocusNode commitFocus;
@@ -232,19 +184,12 @@ class _CommitInput extends StatelessWidget {
label: 'commit message',
textField: true,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: tokens.globalBorder),
),
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: EditableText(
controller: commitMsg,
focusNode: commitFocus,
style: TextStyle(
fontFamily: clideUiFamily,
fontWeight: clideUiDefaultWeight,
fontSize: clideFontCaption,
color: tokens.globalForeground,
),
style: TextStyle(fontFamily: clideUiFamily, fontWeight: clideUiDefaultWeight, fontSize: clideFontCaption, color: tokens.globalForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 3,
@@ -268,9 +213,11 @@ class _CommitInput extends StatelessWidget {
void _doCommit() {
final msg = commitMsg.text.trim();
if (msg.isEmpty) return;
unawaited(controller.commit(msg).then((hash) {
if (hash != null) commitMsg.clear();
}));
unawaited(
controller.commit(msg).then((hash) {
if (hash != null) commitMsg.clear();
}),
);
}
}
@@ -281,14 +228,7 @@ class _GroupAction {
}
class _FileGroup extends StatelessWidget {
const _FileGroup({
required this.label,
required this.entries,
this.actions = const [],
this.onStage,
this.onUnstage,
this.onDiscard,
});
const _FileGroup({required this.label, required this.entries, this.actions = const [], this.onStage, this.onUnstage, this.onDiscard});
final String label;
final List<Map<String, Object?>> entries;
@@ -309,39 +249,20 @@ class _FileGroup extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
'$label (${entries.length})',
fontSize: clideFontCaption,
muted: true,
color: tokens.sidebarForeground,
),
child: ClideText('$label (${entries.length})', fontSize: clideFontCaption, muted: true, color: tokens.sidebarForeground),
),
for (final a in actions) ...[
_SmallAction(label: a.label, onTap: a.onTap),
const SizedBox(width: 4),
],
for (final a in actions) ...[_SmallAction(label: a.label, onTap: a.onTap), const SizedBox(width: 4)],
],
),
),
for (final entry in entries)
_GitFileRow(
entry: entry,
onStage: onStage,
onUnstage: onUnstage,
onDiscard: onDiscard,
),
for (final entry in entries) _GitFileRow(entry: entry, onStage: onStage, onUnstage: onUnstage, onDiscard: onDiscard),
],
);
}
}
class _GitFileRow extends StatelessWidget {
const _GitFileRow({
required this.entry,
this.onStage,
this.onUnstage,
this.onDiscard,
});
const _GitFileRow({required this.entry, this.onStage, this.onUnstage, this.onDiscard});
final Map<String, Object?> entry;
final void Function(String path)? onStage;
@@ -371,39 +292,15 @@ class _GitFileRow extends StatelessWidget {
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
child: Row(
children: [
ClideText(
_stateIndicator(state),
fontSize: clideFontCaption,
color: _stateColor(state, tokens),
),
ClideText(_stateIndicator(state), fontSize: clideFontCaption, color: _stateColor(state, tokens)),
const SizedBox(width: 6),
Expanded(
child: ClideText(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
),
if (hovered) ...[
if (onStage != null)
_SmallAction(
label: '+',
semanticsLabel: 'stage $name',
onTap: () => onStage!(path),
),
if (onUnstage != null)
_SmallAction(
label: '-',
semanticsLabel: 'unstage $name',
onTap: () => onUnstage!(path),
),
if (onDiscard != null)
_SmallAction(
label: 'x',
semanticsLabel: 'discard changes to $name',
onTap: () => onDiscard!(path),
),
if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
],
],
),
@@ -447,11 +344,7 @@ class _GitFileRow extends StatelessWidget {
}
class _SmallAction extends StatelessWidget {
const _SmallAction({
required this.label,
required this.onTap,
this.semanticsLabel,
});
const _SmallAction({required this.label, required this.onTap, this.semanticsLabel});
final String label;
final String? semanticsLabel;
@@ -467,11 +360,7 @@ class _SmallAction extends StatelessWidget {
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText(
label,
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
),
);
@@ -479,11 +368,7 @@ class _SmallAction extends StatelessWidget {
}
class _DiscardConfirmDialog extends StatelessWidget {
const _DiscardConfirmDialog({
required this.path,
required this.onConfirm,
required this.onCancel,
});
const _DiscardConfirmDialog({required this.path, required this.onConfirm, required this.onCancel});
final String path;
final VoidCallback onConfirm;
@@ -505,30 +390,16 @@ class _DiscardConfirmDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
'Discard changes?',
color: tokens.globalForeground,
),
ClideText('Discard changes?', color: tokens.globalForeground),
const SizedBox(height: 8),
ClideText(
'Unstaged changes to $name will be permanently lost.',
fontSize: clideFontCaption,
color: tokens.statusError,
),
ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: 'Cancel',
variant: ClideButtonVariant.subtle,
onPressed: onCancel,
),
ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
const SizedBox(width: 8),
ClideButton(
label: 'Discard',
onPressed: onConfirm,
),
ClideButton(label: 'Discard', onPressed: onConfirm),
],
),
],
+8 -40
View File
@@ -51,13 +51,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
void _openBranchPicker() {
final kernel = ClideKernel.of(context);
kernel.dialog.show<String>(
(ctx, dismiss) => _BranchPicker(
ipc: kernel.ipc,
currentBranch: _branch,
onDismiss: dismiss,
),
);
kernel.dialog.show<String>((ctx, dismiss) => _BranchPicker(ipc: kernel.ipc, currentBranch: _branch, onDismiss: dismiss));
}
@override
@@ -79,17 +73,9 @@ class _GitStatusItemState extends State<GitStatusItem> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(
const GitBranchIcon(),
size: 12,
color: tokens.statusBarForeground,
),
ClideIcon(const GitBranchIcon(), size: 12, color: tokens.statusBarForeground),
const SizedBox(width: 4),
ClideText(
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.statusBarForeground),
],
),
),
@@ -100,11 +86,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
}
class _BranchPicker extends StatefulWidget {
const _BranchPicker({
required this.ipc,
required this.currentBranch,
required this.onDismiss,
});
const _BranchPicker({required this.ipc, required this.currentBranch, required this.onDismiss});
final DaemonClient ipc;
final String? currentBranch;
@@ -139,9 +121,7 @@ class _BranchPickerState extends State<_BranchPicker> {
setState(() {
_loading = false;
if (r.ok) {
_branches = [
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
];
_branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>()];
} else {
_error = r.error?.message ?? 'failed to load branches';
}
@@ -210,11 +190,7 @@ class _BranchPickerState extends State<_BranchPicker> {
final b = _branches[i];
final name = b['name'] as String? ?? '';
final current = b['current'] as bool? ?? false;
return _BranchRow(
name: name,
current: current,
onTap: current ? null : () => unawaited(_checkout(name)),
);
return _BranchRow(name: name, current: current, onTap: current ? null : () => unawaited(_checkout(name)));
},
),
),
@@ -226,11 +202,7 @@ class _BranchPickerState extends State<_BranchPicker> {
}
class _BranchRow extends StatelessWidget {
const _BranchRow({
required this.name,
required this.current,
this.onTap,
});
const _BranchRow({required this.name, required this.current, this.onTap});
final String name;
final bool current;
@@ -250,11 +222,7 @@ class _BranchRow extends StatelessWidget {
if (current)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(
const CheckIcon(),
size: 12,
color: tokens.statusSuccess,
),
child: ClideIcon(const CheckIcon(), size: 12, color: tokens.statusSuccess),
)
else
const SizedBox(width: 20),
+11 -11
View File
@@ -26,9 +26,12 @@ class _GraphViewState extends State<GraphView> {
Future<void> _load() async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('pql.exec', args: {
'argv': ['search', '--connections', '--limit', '50'],
});
final resp = await kernel.ipc.request(
'pql.exec',
args: {
'argv': ['search', '--connections', '--limit', '50'],
},
);
if (!mounted) return;
if (!resp.ok) {
setState(() {
@@ -62,10 +65,7 @@ class _GraphViewState extends State<GraphView> {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
}
if (_nodes.isEmpty) {
return const Padding(
padding: EdgeInsets.all(12),
child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true),
);
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
}
return ListView.builder(
itemCount: _nodes.length,
@@ -84,10 +84,10 @@ class _GraphNode {
final int outbound;
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
);
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
);
}
class _NodeRow extends StatelessWidget {
+1 -7
View File
@@ -10,11 +10,5 @@ class IpcStatusExtension extends ClideExtension {
String get version => '0.2.0';
@override
List<ContributionPoint> get contributions => [
StatusItemContribution(
id: 'ipc-status.indicator',
priority: 100,
build: (_) => const ToolStatusItem(),
),
];
List<ContributionPoint> get contributions => [StatusItemContribution(id: 'ipc-status.indicator', priority: 100, build: (_) => const ToolStatusItem())];
}
+5 -1
View File
@@ -36,7 +36,11 @@ class ToolStatusItem extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
ClideText(label, fontSize: clideFontCaption, color: color),
],
+12 -17
View File
@@ -28,24 +28,19 @@ class KeybindingsUiExtension extends ClideExtension {
/// Presets that ship today, each exposed as a `keymap.preset.<name>`
/// command that activates it.
static const _presets = <String, String>{
'default': 'Keymap: Default',
'vim': 'Keymap: Vim',
'vscode': 'Keymap: VS Code',
'jetbrains': 'Keymap: JetBrains',
};
static const _presets = <String, String>{'default': 'Keymap: Default', 'vim': 'Keymap: Vim', 'vscode': 'Keymap: VS Code', 'jetbrains': 'Keymap: JetBrains'};
@override
List<ContributionPoint> get contributions => [
for (final entry in _presets.entries)
CommandContribution(
id: 'keymap.preset.${entry.key}',
command: 'keymap.preset.${entry.key}',
title: entry.value,
run: (_) async {
await _keymap?.setPreset(entry.key);
return IpcResponse.ok(id: '', data: {'preset': entry.key});
},
),
];
for (final entry in _presets.entries)
CommandContribution(
id: 'keymap.preset.${entry.key}',
command: 'keymap.preset.${entry.key}',
title: entry.value,
run: (_) async {
await _keymap?.setPreset(entry.key);
return IpcResponse.ok(id: '', data: {'preset': entry.key});
},
),
];
}
+8 -8
View File
@@ -19,14 +19,14 @@ class MarkdownExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'markdown.viewer',
slot: Slots.contextPanel,
title: 'Markdown',
icon: PhosphorIcons.byName('file-text'),
build: (_) => const MarkdownViewer(),
),
];
TabContribution(
id: 'markdown.viewer',
slot: Slots.contextPanel,
title: 'Markdown',
icon: PhosphorIcons.byName('file-text'),
build: (_) => const MarkdownViewer(),
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
@@ -95,18 +95,12 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
}
if (_content == null) {
return const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Select a .md file to preview it here.', muted: true),
);
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a .md file to preview it here.', muted: true));
}
return ClidePaneChrome(
title: _path ?? 'viewer',
subtitle: '${_content!.split('\n').length} lines',
leading: ReaderPinButton(
pinned: _nav?.hasPinned ?? false,
onTap: _path != null ? _onPin : null,
),
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _path != null ? _onPin : null),
trailing: [
ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false,
+7 -2
View File
@@ -66,7 +66,9 @@ class _Kv extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)),
Expanded(child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
Expanded(
child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
),
);
@@ -89,7 +91,10 @@ class _Licenses extends StatelessWidget {
final deps = snap.data!.dependencies;
return Container(
constraints: const BoxConstraints(maxHeight: 260),
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder), borderRadius: BorderRadius.circular(4)),
decoration: BoxDecoration(
border: Border.all(color: tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
+69 -59
View File
@@ -26,67 +26,77 @@ class MenuBarExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'file.openFolder',
command: 'file.openFolder',
title: 'File: Open Folder…',
run: (_) async {
await _file.openFolder();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.newWindow',
command: 'file.newWindow',
title: 'File: New Window',
run: (_) async {
_file.newWindow();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.closeWorkspace',
command: 'file.closeWorkspace',
title: 'File: Close Project',
run: (_) async {
_file.closeWorkspace();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'help.about',
command: 'help.about',
title: 'Help: About clide',
run: (_) async {
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {});
},
),
];
CommandContribution(
id: 'file.openFolder',
command: 'file.openFolder',
title: 'File: Open Folder…',
run: (_) async {
await _file.openFolder();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.newWindow',
command: 'file.newWindow',
title: 'File: New Window',
run: (_) async {
_file.newWindow();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.closeWorkspace',
command: 'file.closeWorkspace',
title: 'File: Close Project',
run: (_) async {
_file.closeWorkspace();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'help.about',
command: 'help.about',
title: 'Help: About clide',
run: (_) async {
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {});
},
),
];
}
/// The curated File / View / Help tree (T-48). View ends with a `view.*`
/// auto-fill so newly-registered view commands surface without edits here.
List<TopMenu> buildClideMenuTree() => [
TopMenu(title: 'File', mnemonic: 0, nodes: [
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
const MenuSeparator(),
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
]),
TopMenu(title: 'View', mnemonic: 0, nodes: const [
MenuCommandItem('view.zoomIn'),
MenuCommandItem('view.zoomOut'),
MenuCommandItem('view.zoomReset'),
MenuSeparator(),
MenuCommandItem('sidebar.collapse'),
MenuCommandItem('context.collapse'),
MenuCommandItem('dock.toggle'),
MenuCommandItem('panel.focusMode'),
MenuSeparator(),
MenuAutoFill('view.'),
]),
TopMenu(title: 'Help', mnemonic: 0, nodes: const [
MenuCommandItem('help.about', fallbackTitle: 'About clide'),
]),
];
TopMenu(
title: 'File',
mnemonic: 0,
nodes: [
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
const MenuSeparator(),
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
],
),
TopMenu(
title: 'View',
mnemonic: 0,
nodes: const [
MenuCommandItem('view.zoomIn'),
MenuCommandItem('view.zoomOut'),
MenuCommandItem('view.zoomReset'),
MenuSeparator(),
MenuCommandItem('sidebar.collapse'),
MenuCommandItem('context.collapse'),
MenuCommandItem('dock.toggle'),
MenuCommandItem('panel.focusMode'),
MenuSeparator(),
MenuAutoFill('view.'),
],
),
TopMenu(
title: 'Help',
mnemonic: 0,
nodes: const [MenuCommandItem('help.about', fallbackTitle: 'About clide')],
),
];
+3 -12
View File
@@ -143,10 +143,7 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: 12),
],
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: 12)],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -188,17 +185,11 @@ class NotARepoDialog extends StatelessWidget {
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8),
const ClideText(
'A clide project root requires a git repository.',
muted: true,
fontSize: 13,
),
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: 13),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
),
],
),
+3 -14
View File
@@ -72,18 +72,11 @@ class MenuBar extends StatelessWidget {
return ListenableBuilder(
listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
builder: (ctx, _) {
final menus = resolveMenus(
buildClideMenuTree(),
kernel.commands,
kernel,
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
);
final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id));
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel),
],
children: [for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel)],
);
},
);
@@ -189,11 +182,7 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
color: open || hovered ? tokens.listItemHoverBackground : null,
child: ClideText(
widget.menu.title,
fontSize: 12,
color: open || hovered ? tokens.globalForeground : tokens.chromeForeground,
),
child: ClideText(widget.menu.title, fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
),
),
),
+5 -12
View File
@@ -108,12 +108,7 @@ String? keymapBindingLabel(KeymapService keymap, String commandId) {
/// menus. [bindingLabel] supplies the keybinding string for a command id
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
/// the command's own `defaultBinding` is used as a fallback.
List<ResolvedMenu> resolveMenus(
List<TopMenu> tree,
CommandRegistry registry,
KernelServices services, {
String? Function(String commandId)? bindingLabel,
}) {
List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
final placed = <String>{
for (final m in tree)
for (final n in m.nodes)
@@ -140,12 +135,10 @@ List<ResolvedMenu> resolveMenus(
}
List<ResolvedNode> expand(MenuNode n) => switch (n) {
MenuCommandItem() => [resolveItem(n)],
MenuSeparator() => const [ResolvedSeparator()],
MenuAutoFill(:final prefix) => [
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
],
};
MenuCommandItem() => [resolveItem(n)],
MenuSeparator() => const [ResolvedSeparator()],
MenuAutoFill(:final prefix) => [for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command))],
};
return [
for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]),
+2 -2
View File
@@ -60,8 +60,8 @@ class _DockStatusItemState extends State<DockStatusItem> {
final (String badge, Color color) = errors > 0
? ('$errors', tokens.statusError)
: warns > 0
? ('$warns', tokens.statusWarning)
: ('', tokens.statusSuccess);
? ('$warns', tokens.statusWarning)
: ('', tokens.statusSuccess);
return Semantics(
button: true,
label: 'toggle output dock',
+30 -30
View File
@@ -30,34 +30,34 @@ class OutputExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'output.panel',
slot: Slots.dock,
title: 'Output',
priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
),
StatusItemContribution(
id: 'output.dock-toggle',
priority: 100, // right group, replacing the old app-status item
build: (_) => const DockStatusItem(),
),
CommandContribution(
id: 'dock.toggle',
command: 'dock.toggle',
title: 'Toggle output dock',
defaultBinding: 'ctrl+j',
run: (_) async {
final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
final a = ctx.arrangement;
final opening = !a.isVisible(Slots.dock);
a.setVisible(Slots.dock, opening);
if (opening && ctx.panels.activeTabIn(Slots.dock) == null) {
ctx.panels.activateTab(Slots.dock, 'output.panel');
}
return IpcResponse.ok(id: '', data: {'dock': opening});
},
),
];
TabContribution(
id: 'output.panel',
slot: Slots.dock,
title: 'Output',
priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
),
StatusItemContribution(
id: 'output.dock-toggle',
priority: 100, // right group, replacing the old app-status item
build: (_) => const DockStatusItem(),
),
CommandContribution(
id: 'dock.toggle',
command: 'dock.toggle',
title: 'Toggle output dock',
defaultBinding: 'ctrl+j',
run: (_) async {
final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
final a = ctx.arrangement;
final opening = !a.isVisible(Slots.dock);
a.setVisible(Slots.dock, opening);
if (opening && ctx.panels.activeTabIn(Slots.dock) == null) {
ctx.panels.activateTab(Slots.dock, 'output.panel');
}
return IpcResponse.ok(id: '', data: {'dock': opening});
},
),
];
}
+17 -25
View File
@@ -84,10 +84,7 @@ class _OutputViewState extends State<OutputView> {
child: rows.isEmpty
? Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.',
muted: true,
),
child: ClideText(widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', muted: true),
)
: Stack(
children: [
@@ -100,12 +97,7 @@ class _OutputViewState extends State<OutputView> {
children: [for (final r in rows) _LogRow(record: r)],
),
),
if (!_following)
Positioned(
right: 12,
bottom: 8,
child: _JumpPill(onTap: _jumpToLatest),
),
if (!_following) Positioned(right: 12, bottom: 8, child: _JumpPill(onTap: _jumpToLatest)),
],
),
),
@@ -128,15 +120,9 @@ class _OutputViewState extends State<OutputView> {
child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
),
const SizedBox(width: 8),
_Chip(
label: 'Level: ${_c.minLevel.name}',
onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]),
),
_Chip(label: 'Level: ${_c.minLevel.name}', onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length])),
const SizedBox(width: 6),
_Chip(
label: 'Source: ${_c.source ?? 'all'}',
onTap: _cycleSource,
),
_Chip(label: 'Source: ${_c.source ?? 'all'}', onTap: _cycleSource),
const SizedBox(width: 6),
_Chip(label: 'Clear', onTap: _c.clear),
],
@@ -235,8 +221,14 @@ class _LogRow extends StatelessWidget {
const SizedBox(width: 8),
SizedBox(
width: 92,
child: ClideText(record.source,
fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
child: ClideText(
record.source,
fontSize: clideFontMono,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.clip,
),
),
const SizedBox(width: 8),
Expanded(
@@ -248,9 +240,9 @@ class _LogRow extends StatelessWidget {
}
Color _levelColor(LogLevel level, SurfaceTokens tokens) => switch (level) {
LogLevel.error => tokens.statusError,
LogLevel.warn => tokens.statusWarning,
LogLevel.info => tokens.globalForeground,
LogLevel.debug || LogLevel.trace => tokens.globalTextMuted,
};
LogLevel.error => tokens.statusError,
LogLevel.warn => tokens.statusWarning,
LogLevel.info => tokens.globalForeground,
LogLevel.debug || LogLevel.trace => tokens.globalTextMuted,
};
}
+8 -41
View File
@@ -42,13 +42,7 @@ class _BacklinksViewState extends State<BacklinksView> {
builder: (context, _) {
final tokens = ClideTheme.of(context).surface;
if (c.activePath == null) {
return const Padding(
padding: EdgeInsets.all(12),
child: ClideText(
'Open a file to see its links.',
muted: true,
),
);
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
}
return Semantics(
label: 'backlinks for ${c.activePath}',
@@ -62,35 +56,16 @@ class _BacklinksViewState extends State<BacklinksView> {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(
c.activePath!.split('/').last,
color: tokens.globalForeground,
),
child: ClideText(c.activePath!.split('/').last, color: tokens.globalForeground),
),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(
c.error!,
color: tokens.statusError,
fontSize: clideFontCaption,
),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
),
if (c.loading)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
_LinkGroup(
label: 'Backlinks',
links: c.backlinks,
pathKey: 'source',
),
_LinkGroup(
label: 'Outlinks',
links: c.outlinks,
pathKey: 'target',
),
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
],
),
),
@@ -101,11 +76,7 @@ class _BacklinksViewState extends State<BacklinksView> {
}
class _LinkGroup extends StatelessWidget {
const _LinkGroup({
required this.label,
required this.links,
required this.pathKey,
});
const _LinkGroup({required this.label, required this.links, required this.pathKey});
final String label;
final List<Map<String, Object?>> links;
@@ -119,11 +90,7 @@ class _LinkGroup extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
child: ClideText(
'$label (${links.length})',
fontSize: clideFontCaption,
muted: true,
),
child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
),
if (links.isEmpty)
const Padding(
+9 -9
View File
@@ -17,13 +17,13 @@ class PqlExtension extends ClideExtension {
// tab (T-201); this extension keeps only the Backlinks context panel.
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'pql.backlinks',
slot: Slots.contextPanel,
title: 'Links',
icon: PhosphorIcons.byName('link'),
priority: -80,
build: (_) => const BacklinksView(),
),
];
TabContribution(
id: 'pql.backlinks',
slot: Slots.contextPanel,
title: 'Links',
icon: PhosphorIcons.byName('link'),
priority: -80,
build: (_) => const BacklinksView(),
),
];
}
+3 -12
View File
@@ -71,10 +71,7 @@ class PqlController extends ChangeNotifier {
_error = null;
notifyListeners();
final r = await ipc.request('pql.search', args: {
'terms': terms,
'limit': 50,
});
final r = await ipc.request('pql.search', args: {'terms': terms, 'limit': 50});
_loading = false;
if (!r.ok) {
@@ -91,10 +88,7 @@ class PqlController extends ChangeNotifier {
_loading = true;
notifyListeners();
final r = await ipc.request('pql.files', args: {
'glob': glob ?? '**/*.md',
'limit': 200,
});
final r = await ipc.request('pql.files', args: {'glob': glob ?? '**/*.md', 'limit': 200});
_loading = false;
if (!r.ok) {
@@ -113,10 +107,7 @@ class PqlController extends ChangeNotifier {
_error = null;
notifyListeners();
final r = await ipc.request('pql.query', args: {
'query': dsl,
'limit': 200,
});
final r = await ipc.request('pql.query', args: {'query': dsl, 'limit': 200});
_loading = false;
if (!r.ok) {
+4 -13
View File
@@ -59,8 +59,8 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
.on<DaemonEvent>()
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
.listen((_) {
if (widget.controller.view == PqlView.markdown) unawaited(widget.controller.loadMarkdownFiles());
});
if (widget.controller.view == PqlView.markdown) unawaited(widget.controller.loadMarkdownFiles());
});
}
@override
@@ -187,10 +187,7 @@ class _SearchResultRow extends StatelessWidget {
},
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(4),
),
decoration: BoxDecoration(color: hovered ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(4)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -259,13 +256,7 @@ class _FileRow extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null,
),
child: ClideText(
path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
child: ClideText(path, maxLines: 1, overflow: TextOverflow.ellipsis, fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
),
);
+12 -12
View File
@@ -14,16 +14,16 @@ class ProblemsExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
// Moved out of the sidebar into the bottom dock (D-87): no duplication,
// and the dock's width fits `severity · file:line · message` rows.
TabContribution(
id: 'problems.panel',
slot: Slots.dock,
title: 'Problems',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -50,
build: (_) => const ProblemsView(),
),
];
// Moved out of the sidebar into the bottom dock (D-87): no duplication,
// and the dock's width fits `severity · file:line · message` rows.
TabContribution(
id: 'problems.panel',
slot: Slots.dock,
title: 'Problems',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -50,
build: (_) => const ProblemsView(),
),
];
}
@@ -16,11 +16,7 @@ class Problem {
final String message;
final String? hint;
Map<String, Object?> toJson() => {
'source': source,
'message': message,
if (hint != null) 'hint': hint,
};
Map<String, Object?> toJson() => {'source': source, 'message': message, if (hint != null) 'hint': hint};
}
class ProblemsController extends ChangeNotifier {
@@ -47,11 +43,7 @@ class ProblemsController extends ChangeNotifier {
if (doctor.ok) {
final db = (doctor.data['db'] as Map?)?.cast<String, Object?>();
if (db != null && db['exists'] == false) {
found.add(const Problem(
source: 'pql',
message: 'pql index database not found',
hint: 'Run pql to build the index.',
));
found.add(const Problem(source: 'pql', message: 'pql index database not found', hint: 'Run pql to build the index.'));
}
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
if (skill != null) {
@@ -59,37 +51,21 @@ class ProblemsController extends ChangeNotifier {
if (project != null) {
final state = project['state'] as String?;
if (state == 'stale') {
found.add(const Problem(
source: 'pql',
message: 'pql skill is stale — newer version available',
hint: 'Run: pql skill install',
));
found.add(const Problem(source: 'pql', message: 'pql skill is stale — newer version available', hint: 'Run: pql skill install'));
} else if (state == 'missing') {
found.add(const Problem(
source: 'pql',
message: 'pql skill not installed',
hint: 'Run: pql init --with-skill=yes',
));
found.add(const Problem(source: 'pql', message: 'pql skill not installed', hint: 'Run: pql init --with-skill=yes'));
}
}
}
} else {
found.add(Problem(
source: 'pql',
message: 'pql doctor failed',
hint: doctor.error?.message,
));
found.add(Problem(source: 'pql', message: 'pql doctor failed', hint: doctor.error?.message));
}
final sync = await ipc.request('pql.decisions.sync');
if (sync.ok) {
final broken = (sync.data['broken'] as num?)?.toInt() ?? 0;
if (broken > 0) {
found.add(Problem(
source: 'decisions',
message: '$broken broken cross-reference(s) in governance/',
hint: 'Run: pql decisions validate',
));
found.add(Problem(source: 'decisions', message: '$broken broken cross-reference(s) in governance/', hint: 'Run: pql decisions validate'));
}
}
+12 -23
View File
@@ -49,8 +49,9 @@ class _ProblemsViewState extends State<ProblemsView> {
explicitChildNodes: true,
child: () {
final lf = _filter.toLowerCase();
final filtered =
lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
final filtered = lf.isEmpty
? c.problems
: c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -59,14 +60,18 @@ class _ProblemsViewState extends State<ProblemsView> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Expanded(child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
Expanded(
child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
Semantics(
button: true,
label: 'refresh problems',
child: GestureDetector(
onTap: () => unawaited(c.refresh()),
child: MouseRegion(
cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
cursor: SystemMouseCursors.click,
child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
),
),
],
@@ -108,31 +113,15 @@ class _ProblemRow extends StatelessWidget {
children: [
Row(
children: [
ClideText(
problem.source,
fontSize: clideFontMono,
color: tokens.statusWarning,
fontFamily: clideMonoFamily,
),
ClideText(problem.source, fontSize: clideFontMono, color: tokens.statusWarning, fontFamily: clideMonoFamily),
const SizedBox(width: 6),
Expanded(
child: ClideText(
problem.message,
color: tokens.sidebarForeground,
maxLines: 2,
),
),
Expanded(child: ClideText(problem.message, color: tokens.sidebarForeground, maxLines: 2)),
],
),
if (problem.hint != null)
Padding(
padding: const EdgeInsets.only(left: 44, top: 2),
child: ClideText(
problem.hint!,
fontSize: clideFontMono,
muted: true,
fontFamily: clideMonoFamily,
),
child: ClideText(problem.hint!, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
),
],
),
+9 -9
View File
@@ -19,13 +19,13 @@ class SearchExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'search.findInFiles',
slot: Slots.sidebar,
title: 'Search',
icon: PhosphorIcons.byName('magnifying-glass'),
priority: -90,
build: (_) => const SearchPanelView(),
),
];
TabContribution(
id: 'search.findInFiles',
slot: Slots.sidebar,
title: 'Search',
icon: PhosphorIcons.byName('magnifying-glass'),
priority: -90,
build: (_) => const SearchPanelView(),
),
];
}
@@ -86,15 +86,18 @@ class FindInFilesController extends ChangeNotifier {
/// results. Returns the number of files changed and matches replaced.
/// Callers must gate on [isWorkingTreeClean] + user confirmation first.
Future<({int files, int count})> applyReplace() async {
final r = await ipc.request('search.replace', args: {
'pattern': pattern,
'regex': regex,
'ignoreCase': ignoreCase,
'include': _split(includeGlobs),
'exclude': _split(excludeGlobs),
'replacement': replacement,
'apply': true,
});
final r = await ipc.request(
'search.replace',
args: {
'pattern': pattern,
'regex': regex,
'ignoreCase': ignoreCase,
'include': _split(includeGlobs),
'exclude': _split(excludeGlobs),
'replacement': replacement,
'apply': true,
},
);
final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0;
final count = (r.data['totalCount'] as num?)?.toInt() ?? 0;
await run(pattern); // refresh the match list against the new content
@@ -123,13 +126,10 @@ class FindInFilesController extends ChangeNotifier {
_running = true;
notifyListeners();
final resp = await ipc.request('search.grep', args: {
'pattern': pattern,
'regex': regex,
'ignoreCase': ignoreCase,
'include': _split(includeGlobs),
'exclude': _split(excludeGlobs),
});
final resp = await ipc.request(
'search.grep',
args: {'pattern': pattern, 'regex': regex, 'ignoreCase': ignoreCase, 'include': _split(includeGlobs), 'exclude': _split(excludeGlobs)},
);
if (!resp.ok) {
_error = resp.error?.message ?? 'search failed';
_running = false;
+57 -71
View File
@@ -50,17 +50,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
if (c.replacement.isEmpty || c.matchCount == 0) return;
final dialog = ClideKernel.of(context).dialog;
if (!await c.isWorkingTreeClean()) {
await dialog.show<Object>((ctx, dismiss) => _MessageDialog(
title: 'Working tree not clean',
body: 'Commit or stash your changes before replacing — git is the only undo.',
dismiss: dismiss,
));
await dialog.show<Object>(
(ctx, dismiss) =>
_MessageDialog(title: 'Working tree not clean', body: 'Commit or stash your changes before replacing — git is the only undo.', dismiss: dismiss),
);
return;
}
final confirmed = await dialog.show<bool>((ctx, dismiss) => _ConfirmDialog(
body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.',
dismiss: dismiss,
));
final confirmed = await dialog.show<bool>(
(ctx, dismiss) =>
_ConfirmDialog(body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', dismiss: dismiss),
);
if (confirmed != true) return;
await c.applyReplace();
}
@@ -138,26 +137,34 @@ class _SearchPanelViewState extends State<SearchPanelView> {
Row(
children: [
Expanded(
child: ClideFilterBox(
address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
const SizedBox(width: 6),
_ReplaceAllButton(
enabled: c.replacement.isNotEmpty && c.matchCount > 0,
tokens: tokens,
onTap: _replaceAll,
child: ClideFilterBox(
address: 'search.findInFiles.replace',
hint: 'Replace',
icon: null,
debounce: Duration.zero,
onChanged: c.setReplacement,
),
),
const SizedBox(width: 6),
_ReplaceAllButton(enabled: c.replacement.isNotEmpty && c.matchCount > 0, tokens: tokens, onTap: _replaceAll),
],
),
const SizedBox(height: 6),
ClideFilterBox(
address: 'search.findInFiles.include',
hint: 'files to include (e.g. *.dart)',
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.include = v),
address: 'search.findInFiles.include',
hint: 'files to include (e.g. *.dart)',
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.include = v,
),
const SizedBox(height: 4),
ClideFilterBox(
address: 'search.findInFiles.exclude', hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
address: 'search.findInFiles.exclude',
hint: 'files to exclude',
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.exclude = v,
),
],
),
),
@@ -171,14 +178,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
padding: EdgeInsets.zero,
children: [
for (final entry in groups.entries)
_FileGroup(
path: entry.key,
matches: entry.value,
tokens: tokens,
onTap: c.openMatch,
query: c.query,
replacement: c.replacement,
),
_FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch, query: c.query, replacement: c.replacement),
],
),
),
@@ -196,18 +196,15 @@ class _ModeSwitcher extends StatelessWidget {
final SurfaceTokens tokens;
final ValueChanged<SearchTabMode> onSelect;
static const _labels = {
SearchTabMode.find: 'Find',
SearchTabMode.vault: 'Vault',
SearchTabMode.query: 'Query',
SearchTabMode.markdown: 'Markdown',
};
static const _labels = {SearchTabMode.find: 'Find', SearchTabMode.vault: 'Vault', SearchTabMode.query: 'Query', SearchTabMode.markdown: 'Markdown'};
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.panelBorder))),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
for (final m in SearchTabMode.values) ...[
@@ -233,13 +230,7 @@ class _ModeSwitcher extends StatelessWidget {
}
class _Toggle extends StatelessWidget {
const _Toggle({
required this.label,
required this.tooltip,
required this.active,
required this.tokens,
required this.onTap,
});
const _Toggle({required this.label, required this.tooltip, required this.active, required this.tokens, required this.onTap});
final String label;
final String tooltip;
@@ -296,14 +287,7 @@ class _StatusText extends StatelessWidget {
}
class _FileGroup extends StatelessWidget {
const _FileGroup({
required this.path,
required this.matches,
required this.tokens,
required this.onTap,
required this.query,
required this.replacement,
});
const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap, required this.query, required this.replacement});
final String path;
final List<SearchMatch> matches;
@@ -336,13 +320,7 @@ class _FileGroup extends StatelessWidget {
}
class _MatchRow extends StatelessWidget {
const _MatchRow({
required this.match,
required this.tokens,
required this.onTap,
required this.query,
required this.replacement,
});
const _MatchRow({required this.match, required this.tokens, required this.onTap, required this.query, required this.replacement});
final SearchMatch match;
final SurfaceTokens tokens;
@@ -385,11 +363,17 @@ class _MatchRow extends StatelessWidget {
return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(style: _base, children: [
TextSpan(text: line.substring(0, start)),
TextSpan(text: line.substring(start, end), style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)),
TextSpan(text: line.substring(end)),
]),
text: TextSpan(
style: _base,
children: [
TextSpan(text: line.substring(0, start)),
TextSpan(
text: line.substring(start, end),
style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold),
),
TextSpan(text: line.substring(end)),
],
),
);
}
@@ -403,12 +387,18 @@ class _MatchRow extends StatelessWidget {
RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(text: match.preview, style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted)),
text: TextSpan(
text: match.preview,
style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
),
),
RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(text: after, style: _base.copyWith(color: tokens.globalFocus)),
text: TextSpan(
text: after,
style: _base.copyWith(color: tokens.globalFocus),
),
),
],
);
@@ -437,11 +427,7 @@ class _ReplaceAllButton extends StatelessWidget {
border: Border.all(color: tokens.buttonBorder),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(
'Replace all',
fontSize: clideFontCaption,
color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted,
),
child: ClideText('Replace all', fontSize: clideFontCaption, color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted),
),
),
);
+5 -33
View File
@@ -50,13 +50,7 @@ class ReaderActionBar extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_ActionButton(
painter: PhosphorIcons.byName('caret-left'),
tooltip: 'Back',
enabled: canGoBack,
onTap: canGoBack ? onBack : null,
tokens: tokens,
),
_ActionButton(painter: PhosphorIcons.byName('caret-left'), tooltip: 'Back', enabled: canGoBack, onTap: canGoBack ? onBack : null, tokens: tokens),
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.byName('caret-right'),
@@ -67,23 +61,11 @@ class ReaderActionBar extends StatelessWidget {
),
if (hasPinned) ...[
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.byName('arrow-u-up-left'),
tooltip: 'Jump to pin',
enabled: true,
onTap: onJumpToPin,
tokens: tokens,
),
_ActionButton(painter: PhosphorIcons.byName('arrow-u-up-left'), tooltip: 'Jump to pin', enabled: true, onTap: onJumpToPin, tokens: tokens),
],
if (onEdit != null) ...[
const SizedBox(width: 4),
_ActionButton(
painter: PhosphorIcons.byName('pencil-simple'),
tooltip: 'Edit in editor',
enabled: true,
onTap: onEdit,
tokens: tokens,
),
_ActionButton(painter: PhosphorIcons.byName('pencil-simple'), tooltip: 'Edit in editor', enabled: true, onTap: onEdit, tokens: tokens),
],
],
);
@@ -118,14 +100,7 @@ class ReaderPinButton extends StatelessWidget {
// ---------------------------------------------------------------------------
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.painter,
required this.tooltip,
required this.enabled,
required this.onTap,
required this.tokens,
this.active = false,
});
const _ActionButton({required this.painter, required this.tooltip, required this.enabled, required this.onTap, required this.tokens, this.active = false});
final ClideIconPainter painter;
final String tooltip;
@@ -161,10 +136,7 @@ class _ActionButton extends StatelessWidget {
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered && enabled ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(3),
),
decoration: BoxDecoration(color: hovered && enabled ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(3)),
child: ClideIcon(painter, size: 11, color: color),
),
),
+10 -10
View File
@@ -18,14 +18,14 @@ class TerminalExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'terminal.pane',
slot: Slots.workspace,
title: 'Terminal',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 100,
build: (_) => const TerminalPane(),
),
];
TabContribution(
id: 'terminal.pane',
slot: Slots.workspace,
title: 'Terminal',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 100,
build: (_) => const TerminalPane(),
),
];
}
+13 -23
View File
@@ -69,13 +69,16 @@ class _TerminalPaneState extends State<TerminalPane> {
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
final cwd = Directory.current.path;
final response = await ipc.request('pane.spawn', args: {
'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
});
final response = await ipc.request(
'pane.spawn',
args: {
'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
},
);
if (!mounted) return;
if (!response.ok) {
setState(() => _error = response.error?.message ?? 'spawn failed');
@@ -120,11 +123,7 @@ class _TerminalPaneState extends State<TerminalPane> {
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
final id = _paneId;
if (id == null) return;
_kernelIpc()?.request('pane.resize', args: {
'id': id,
'cols': cols,
'rows': rows,
});
_kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
}
DaemonClient? _kernelIpc() => _kernel()?.ipc;
@@ -144,12 +143,7 @@ class _TerminalPaneState extends State<TerminalPane> {
return ClidePaneChrome(
title: 'terminal',
subtitle: subtitle,
child: _error != null
? _ErrorBody(message: _error!)
: ClidePtyView(
terminal: _terminal,
label: 'terminal — $subtitle',
),
child: _error != null ? _ErrorBody(message: _error!) : ClidePtyView(terminal: _terminal, label: 'terminal — $subtitle'),
);
}
}
@@ -165,11 +159,7 @@ class _ErrorBody extends StatelessWidget {
alignment: Alignment.topLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('Terminal unavailable'),
const SizedBox(height: 4),
ClideText(message, muted: true),
],
children: [const ClideText('Terminal unavailable'), const SizedBox(height: 4), ClideText(message, muted: true)],
),
),
);
+12 -33
View File
@@ -20,46 +20,25 @@ class ThemePickerExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
// Opens the settings modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it); ⌘K opens
// Settings, whose only section today is the theme picker.
CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Settings…',
defaultBinding: 'ctrl+k',
run: _pick,
),
// Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right.
StatusItemContribution(
id: 'theme-picker.switcher',
priority: 110,
build: (_) => const ThemeSwitcherStatusItem(),
),
];
// Opens the settings modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it); ⌘K opens
// Settings, whose only section today is the theme picker.
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick),
// Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right.
StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
];
Future<IpcResponse> _pick(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'theme-picker not activated',
),
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'theme-picker not activated'),
);
}
final selected = await ctx.dialog.show<String>(
(context, dismiss) => SettingsView(
controller: ctx.theme,
onDismiss: dismiss,
),
);
return IpcResponse.ok(id: '', data: {
'selected': selected ?? ctx.theme.currentName,
});
final selected = await ctx.dialog.show<String>((context, dismiss) => SettingsView(controller: ctx.theme, onDismiss: dismiss));
return IpcResponse.ok(id: '', data: {'selected': selected ?? ctx.theme.currentName});
}
}
@@ -10,11 +10,7 @@ import 'package:flutter/widgets.dart';
/// (D-69); the shared `theme_families` helpers keep both surfaces in sync.
/// Selecting a theme applies it live and dismisses; Cancel just closes.
class SettingsView extends StatefulWidget {
const SettingsView({
super.key,
required this.controller,
required this.onDismiss,
});
const SettingsView({super.key, required this.controller, required this.onDismiss});
final ThemeController controller;
final void Function([String? selected]) onDismiss;
@@ -233,9 +229,7 @@ class _ThemeRow extends StatelessWidget {
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(displayName, color: fg),
),
Expanded(child: ClideText(displayName, color: fg)),
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
],
),
@@ -129,19 +129,9 @@ class _ThemePopoverState extends State<_ThemePopover> {
maxWidth: 280,
maxHeight: 360,
entries: [
ClideMenuItem(
label: 'High contrast',
active: _hc,
keepOpenOnSelect: true,
onSelect: _toggleHc,
),
ClideMenuItem(label: 'High contrast', active: _hc, keepOpenOnSelect: true, onSelect: _toggleHc),
const ClideMenuSeparator(),
for (final t in _themes)
ClideMenuItem(
label: t.displayName,
active: t.name == currentBase,
onSelect: () => _pick(t),
),
for (final t in _themes) ClideMenuItem(label: t.displayName, active: t.name == currentBase, onSelect: () => _pick(t)),
],
),
);

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