7 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
451 changed files with 7787 additions and 12875 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);
+39
View File
@@ -16,6 +16,45 @@ 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
+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.2"
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
+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
+56 -110
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: [
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')),
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(
+8 -43
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;
@@ -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;
+46 -123
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(
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.
@@ -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),
),
),
);
@@ -587,11 +559,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_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) {
@@ -639,12 +607,15 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final children = <Widget>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(Padding(
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(
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -654,22 +625,16 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
),
Expanded(
child: ClideText(
r.value,
fontSize: clideFontSmall,
color: r.valueColor ?? tokens.globalForeground,
),
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);
}
}
// T-183: accordion sections for the Config tab.
@@ -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: [
@@ -979,7 +937,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
'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),
),
),
),
+18 -43
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: () {
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(
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;
@@ -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),
);
}
}
@@ -85,11 +85,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// Public entry point used by the `claude.new-secondary` command.
void addSecondary() {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
));
),
);
}
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// without touching the original.
void addFork(String sourceClaudeSessionId) {
final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>(
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
));
),
);
}
@override
+5 -8
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(
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.
+9 -11
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)]),
),
],
],
@@ -84,7 +81,9 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
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)),
Expanded(
child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
),
] else
const Spacer(),
],
@@ -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();
+4 -20
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),
),
),
);
@@ -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);
}
+18 -30
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,
@@ -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.
@@ -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)],
],
),
);
@@ -585,11 +580,7 @@ class _ConversationTurn extends StatelessWidget {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
),
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
),
);
}
@@ -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),
);
}
+8 -19
View File
@@ -254,7 +254,7 @@ class ClaudeExtension extends ClideExtension {
}
}
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
},
),
// claude.agent.fork: branch a managed session into a new fork session
@@ -281,13 +281,7 @@ class ClaudeExtension extends ClideExtension {
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec(
id: forkId,
role: 'fork of $sourceId',
sessionId: forkId,
cwd: cwd,
forkSourceSessionId: source.sessionId,
));
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
},
),
@@ -305,12 +299,7 @@ class ClaudeExtension extends ClideExtension {
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
];
@override
@@ -399,13 +388,15 @@ class ClaudeExtension extends ClideExtension {
}
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return;
target.conversation.inject(ImageMessage(
target.conversation.inject(
ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(),
isSidechain: false,
path: path,
caption: m.data['caption'] as String?,
));
),
);
}
@override
@@ -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'});
}
}
+2 -12
View File
@@ -19,11 +19,7 @@ void openImageLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
),
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
),
);
}
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image(
image: ClideFileImage(path),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
),
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
),
),
),
@@ -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)),
+38 -44
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: [
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: [
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),
],
ClideButton(
label: last ? 'Review ' : 'Next ',
variant: ClideButtonVariant.primary,
onPressed: answered ? () => setState(() => _step++) : null,
),
Row(
children: [
if (_step > 0) ...[ClideButton(label: ' Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
ClideButton(label: last ? 'Review ' : 'Next ', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
const Spacer(),
_chatInstead(tokens),
]),
],
),
],
);
}
@@ -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(
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
color: tokens.globalFocus.withValues(alpha: 0.18),
border: Border.all(color: tokens.statusInfo),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
));
),
);
} else {
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
}
@@ -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),
);
);
// -- 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,
[
_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? ?? ''),
],
),
]),
];
}
+6 -14
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(
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;
@@ -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])),
),
],
),
+35 -28
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',
[
@@ -310,7 +306,8 @@ 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({
_proc.writeLine(
jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request': {
@@ -318,7 +315,8 @@ class StreamJsonSession {
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}));
}),
);
}
}
@@ -448,7 +446,8 @@ class StreamJsonSession {
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final tuid = request['tool_use_id'] as String? ?? '';
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
_queue.add(ToolPrompt(
_queue.add(
ToolPrompt(
promptId: rid,
toolName: toolName,
displayName: request['display_name'] as String? ?? toolName,
@@ -456,7 +455,8 @@ class StreamJsonSession {
toolUseId: request['tool_use_id'] as String? ?? '',
input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
));
),
);
_pendingCtl.add(pendingPrompt);
return; // awaits resolvePrompt
}
@@ -465,10 +465,12 @@ class StreamJsonSession {
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
return;
}
_proc.writeLine(jsonEncode({
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}));
}),
);
}
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
@@ -489,14 +491,16 @@ class StreamJsonSession {
} else {
mcpResponse = await _dispatchMcp(server, message);
}
_proc.writeLine(jsonEncode({
_proc.writeLine(
jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse}
'response': {'mcp_response': mcpResponse},
},
}));
}),
);
}
McpServer? _mcpServerNamed(String? name) {
@@ -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({
_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({
_proc.writeLine(
jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
}));
_items.add(UserMessage(
uuid: 'local-${_localSeq++}',
timestamp: DateTime.now(),
isSidechain: false,
text: text,
));
}),
);
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
_setBusy(true);
}
@@ -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({
_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({
_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
+1 -1
View File
@@ -50,4 +50,4 @@ TaskStatus _statusFrom(Object? raw) => switch (raw) {
'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed,
_ => TaskStatus.pending,
};
};
+6 -22
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,7 +345,8 @@ class TeamMcpServer implements McpServer {
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
case 'task_status':
return _result(
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
);
default:
return _error('Unknown team tool: $name');
}
+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);
}
+14 -76
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 …'),
),
),
],
@@ -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))]),
),
],
),
+5 -2
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: {
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;
@@ -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,
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});
});
}
+40 -35
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.
///
@@ -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;
@@ -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,7 +635,8 @@ void _parseUserInto(
if (text.isNotEmpty) textParts.add(text);
case 'tool_result':
final rawContent = item['content'];
out.add(ToolResultMessage(
out.add(
ToolResultMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
@@ -659,7 +645,8 @@ void _parseUserInto(
toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false,
));
),
);
default:
break;
}
@@ -689,18 +676,35 @@ 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(
out.add(
AssistantToolUse(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
@@ -709,7 +713,8 @@ void _parseAssistantInto(
toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
));
),
);
default:
break;
}
+2 -9
View File
@@ -70,19 +70,12 @@ class CliInstallExtension extends ClideExtension {
final ctx = _ctx;
if (r.ok) {
ctx?.notify.success(r.message, title: 'clide CLI installed');
return IpcResponse.ok(id: '', data: {
'installed': r.installedPath,
'onPath': r.onPath,
});
return IpcResponse.ok(id: '', data: {'installed': r.installedPath, 'onPath': r.onPath});
}
ctx?.notify.error(r.message, title: 'clide CLI install failed');
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: r.message,
),
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
);
},
),
+3 -15
View File
@@ -1,11 +1,7 @@
import 'dart:ui' show Color;
class DecisionTypeColors {
const DecisionTypeColors({
required this.confirmed,
required this.question,
required this.rejected,
});
const DecisionTypeColors({required this.confirmed, required this.question, required this.rejected});
final Color confirmed;
final Color question;
@@ -18,17 +14,9 @@ class DecisionTypeColors {
_ => confirmed,
};
static const dark = DecisionTypeColors(
confirmed: Color(0xFF7DD3A8),
question: Color(0xFFE6C370),
rejected: Color(0xFFE87D7D),
);
static const dark = DecisionTypeColors(confirmed: Color(0xFF7DD3A8), question: Color(0xFFE6C370), rejected: Color(0xFFE87D7D));
static const light = DecisionTypeColors(
confirmed: Color(0xFF1D7A4E),
question: Color(0xFFB08A20),
rejected: Color(0xFFC03030),
);
static const light = DecisionTypeColors(confirmed: Color(0xFF1D7A4E), question: Color(0xFFB08A20), rejected: Color(0xFFC03030));
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
}
@@ -110,10 +110,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
return ClidePaneChrome(
title: id,
subtitle: title,
leading: ReaderPinButton(
pinned: _nav?.hasPinned ?? false,
onTap: _decision != null ? _onPin : null,
),
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _decision != null ? _onPin : null),
trailing: [
ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false,
@@ -144,7 +141,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
children: [
ClideTooltip(
message: type ?? 'confirmed',
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
),
),
const SizedBox(width: 8),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
@@ -159,21 +160,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
),
const SizedBox(height: 8),
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[
const SizedBox(height: 6),
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
],
if (status != null && status != 'active') ...[
const SizedBox(height: 8),
_StatusBadge(status: status, tokens: tokens),
],
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
],
),
),
if (body != null && body.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
],
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
+45 -10
View File
@@ -139,11 +139,13 @@ class _DecisionsViewState extends State<DecisionsView> {
final hasFilter = lf.isNotEmpty;
final filtered = hasFilter
? _decisions
.where((d) =>
.where(
(d) =>
d.id.toLowerCase().contains(lf) ||
d.title.toLowerCase().contains(lf) ||
(d.domain ?? '').toLowerCase().contains(lf) ||
(d.type ?? '').contains(lf))
(d.type ?? '').contains(lf),
)
.toList()
: _decisions;
@@ -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,
),
],
),
],
@@ -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),
+1 -6
View File
@@ -25,12 +25,7 @@ class DeepLinkExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'deeplink.invoke',
command: 'deeplink.invoke',
title: 'Open a clide:// deep link',
run: _invoke,
),
CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke),
];
@override
+9 -60
View File
@@ -16,19 +16,8 @@ class DefaultLayoutExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
_preset ?? classicPreset(),
CommandContribution(
id: 'layout.reset',
command: 'layout.reset',
title: 'Layout: Reset to Classic',
run: _reset,
),
CommandContribution(
id: 'palette.toggle',
command: 'palette.toggle',
title: 'Command Palette',
defaultBinding: 'ctrl+shift+p',
run: _togglePalette,
),
CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset),
CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette),
// Collapse toggles (D-051, D-054)
CommandContribution(
id: 'sidebar.collapse',
@@ -45,35 +34,11 @@ class DefaultLayoutExtension extends ClideExtension {
run: _collapseContext,
),
// Panel focus (D-054)
CommandContribution(
id: 'panel.focus.left',
command: 'panel.focus.left',
title: 'Focus Left Panel',
defaultBinding: 'ctrl+1',
run: _focusLeft,
),
CommandContribution(
id: 'panel.focus.middle',
command: 'panel.focus.middle',
title: 'Focus Middle Panel',
defaultBinding: 'ctrl+2',
run: _focusMiddle,
),
CommandContribution(
id: 'panel.focus.right',
command: 'panel.focus.right',
title: 'Focus Right Panel',
defaultBinding: 'ctrl+3',
run: _focusRight,
),
CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
// Focus mode (D-052, D-054)
CommandContribution(
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
CommandContribution(
id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit',
@@ -86,20 +51,8 @@ class DefaultLayoutExtension extends ClideExtension {
run: _exitFocusMode,
),
// Editor split (D-049, D-054)
CommandContribution(
id: 'editor.open',
command: 'editor.open',
title: 'Open Editor',
defaultBinding: 'ctrl+e',
run: _openEditor,
),
CommandContribution(
id: 'editor.close',
command: 'editor.close',
title: 'Close Editor',
defaultBinding: 'ctrl+w',
run: _closeEditor,
),
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
@@ -314,10 +267,6 @@ class DefaultLayoutExtension extends ClideExtension {
static IpcResponse _notActivated() => IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'not activated',
),
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'not activated'),
);
}
+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,
),
),
for (final lineObj in lines)
_DiffLineRow(
line: (lineObj as Map).cast<String, Object?>(),
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
),
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
],
);
}
@@ -313,18 +271,9 @@ class _DiffLineRow extends StatelessWidget {
final newLineNo = line['newLineNo'] as num?;
final (Color bg, Color fg) = switch (kind) {
'addition' => (
tokens.statusSuccess.withValues(alpha: 0.15),
tokens.statusSuccess,
),
'removal' => (
tokens.statusError.withValues(alpha: 0.15),
tokens.statusError,
),
_ => (
const Color(0x00000000),
tokens.globalForeground,
),
'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
_ => (const Color(0x00000000), tokens.globalForeground),
};
final prefix = switch (kind) {
@@ -361,22 +310,10 @@ class _DiffLineRow extends StatelessWidget {
),
),
const SizedBox(width: 4),
ClideText(
prefix,
fontSize: clideFontMono,
color: fg,
fontFamily: clideMonoFamily,
),
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
const SizedBox(width: 2),
Expanded(
child: ClideText(
text,
fontSize: clideFontMono,
color: fg,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.clip,
),
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
),
],
),
+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 {
+10 -19
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;
}
}
@@ -417,7 +407,8 @@ class _RulerPainter extends CustomPainter {
Offset(x, size.height),
Paint()
..color = color
..strokeWidth = 1);
..strokeWidth = 1,
);
}
@override
@@ -36,7 +36,10 @@ class SyntaxTextController extends TextEditingController {
if (source == _highlightedText) return;
_highlighting = true;
_syntax.highlight(path, source).then((result) {
_syntax
.highlight(path, source)
.then(
(result) {
_highlighting = false;
if (text != source) {
_requestHighlight();
@@ -45,9 +48,11 @@ class SyntaxTextController extends TextEditingController {
_highlightedText = source;
_spans = result.spans;
notifyListeners();
}, onError: (_) {
},
onError: (_) {
_highlighting = false;
});
},
);
}
@override
@@ -57,11 +62,7 @@ class SyntaxTextController extends TextEditingController {
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
final tokens = _tokens;
if (_spans.isEmpty || tokens == null || text.isEmpty) {
return TextSpan(text: text, style: style);
@@ -125,20 +126,17 @@ class SyntaxTextController extends TextEditingController {
// Gap before this span — plain text.
if (spanCharStart > charPos) {
children.add(TextSpan(
text: source.substring(charPos, spanCharStart),
style: style,
));
children.add(TextSpan(text: source.substring(charPos, spanCharStart), style: style));
}
// The highlighted span.
if (spanCharEnd > spanCharStart) {
children.add(TextSpan(
children.add(
TextSpan(
text: source.substring(spanCharStart, spanCharEnd),
style: style?.copyWith(
color: TreeSitterService.colorForRole(span.role, tokens),
style: style?.copyWith(color: TreeSitterService.colorForRole(span.role, tokens)),
),
));
);
}
charPos = spanCharEnd;
@@ -146,10 +144,7 @@ class SyntaxTextController extends TextEditingController {
// Trailing plain text.
if (charPos < source.length) {
children.add(TextSpan(
text: source.substring(charPos),
style: style,
));
children.add(TextSpan(text: source.substring(charPos), style: style));
}
return TextSpan(style: style, children: children);
+71 -25
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))),
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)),
);
}
+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),
),
],
),
+1 -5
View File
@@ -26,10 +26,6 @@ class GitExtension extends ClideExtension {
priority: -80,
build: (_) => const GitPanelView(),
),
StatusItemContribution(
id: 'git.branch',
priority: 10,
build: (_) => const GitStatusItem(),
),
StatusItemContribution(id: 'git.branch', priority: 10, build: (_) => const GitStatusItem()),
];
}
+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;
}
+37 -166
View File
@@ -87,57 +87,26 @@ class _GitPanelViewState extends State<GitPanelView> {
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(
c.error!,
color: tokens.statusError,
fontSize: clideFontCaption,
maxLines: 3,
),
),
if (c.loading && c.isClean)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
),
if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.isClean && c.error == null)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Nothing to commit, working tree clean.', muted: true),
),
if (c.conflicted.isNotEmpty)
_FileGroup(
label: 'Merge conflicts',
entries: _applyFilter(c.conflicted),
actions: const [],
),
const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
entries: _applyFilter(c.staged),
actions: [
_GroupAction(
label: 'Unstage all',
onTap: () => unawaited(c.unstage(const [])),
),
],
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(
commitMsg: _commitMsg,
commitFocus: _commitFocus,
controller: c,
),
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
],
if (c.unstaged.isNotEmpty)
_FileGroup(
label: 'Changes',
entries: _applyFilter(c.unstaged),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () => unawaited(c.stageAll()),
),
],
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
@@ -149,9 +118,7 @@ class _GitPanelViewState extends State<GitPanelView> {
_GroupAction(
label: 'Stage all',
onTap: () {
final paths = [
for (final e in c.untracked) e['path'] as String,
];
final paths = [for (final e in c.untracked) e['path'] as String];
unawaited(c.stage(paths));
},
),
@@ -160,7 +127,8 @@ class _GitPanelViewState extends State<GitPanelView> {
),
],
),
)),
),
),
],
),
);
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
),
_SmallAction(
label: 'Pull',
semanticsLabel: 'git pull',
onTap: () => unawaited(controller.pull()),
child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
const SizedBox(width: 4),
_SmallAction(
label: 'Push',
semanticsLabel: 'git push',
onTap: () => unawaited(controller.push()),
),
_SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
],
),
);
@@ -209,11 +165,7 @@ class _BranchHeader extends StatelessWidget {
}
class _CommitInput extends StatelessWidget {
const _CommitInput({
required this.commitMsg,
required this.commitFocus,
required this.controller,
});
const _CommitInput({required this.commitMsg, required this.commitFocus, required this.controller});
final TextEditingController commitMsg;
final FocusNode commitFocus;
@@ -232,19 +184,12 @@ class _CommitInput extends StatelessWidget {
label: 'commit message',
textField: true,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: tokens.globalBorder),
),
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: EditableText(
controller: commitMsg,
focusNode: commitFocus,
style: TextStyle(
fontFamily: clideUiFamily,
fontWeight: clideUiDefaultWeight,
fontSize: clideFontCaption,
color: tokens.globalForeground,
),
style: TextStyle(fontFamily: clideUiFamily, fontWeight: clideUiDefaultWeight, fontSize: clideFontCaption, color: tokens.globalForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 3,
@@ -268,9 +213,11 @@ class _CommitInput extends StatelessWidget {
void _doCommit() {
final msg = commitMsg.text.trim();
if (msg.isEmpty) return;
unawaited(controller.commit(msg).then((hash) {
unawaited(
controller.commit(msg).then((hash) {
if (hash != null) commitMsg.clear();
}));
}),
);
}
}
@@ -281,14 +228,7 @@ class _GroupAction {
}
class _FileGroup extends StatelessWidget {
const _FileGroup({
required this.label,
required this.entries,
this.actions = const [],
this.onStage,
this.onUnstage,
this.onDiscard,
});
const _FileGroup({required this.label, required this.entries, this.actions = const [], this.onStage, this.onUnstage, this.onDiscard});
final String label;
final List<Map<String, Object?>> entries;
@@ -309,39 +249,20 @@ class _FileGroup extends StatelessWidget {
child: Row(
children: [
Expanded(
child: ClideText(
'$label (${entries.length})',
fontSize: clideFontCaption,
muted: true,
color: tokens.sidebarForeground,
child: ClideText('$label (${entries.length})', fontSize: clideFontCaption, muted: true, color: tokens.sidebarForeground),
),
),
for (final a in actions) ...[
_SmallAction(label: a.label, onTap: a.onTap),
const SizedBox(width: 4),
],
for (final a in actions) ...[_SmallAction(label: a.label, onTap: a.onTap), const SizedBox(width: 4)],
],
),
),
for (final entry in entries)
_GitFileRow(
entry: entry,
onStage: onStage,
onUnstage: onUnstage,
onDiscard: onDiscard,
),
for (final entry in entries) _GitFileRow(entry: entry, onStage: onStage, onUnstage: onUnstage, onDiscard: onDiscard),
],
);
}
}
class _GitFileRow extends StatelessWidget {
const _GitFileRow({
required this.entry,
this.onStage,
this.onUnstage,
this.onDiscard,
});
const _GitFileRow({required this.entry, this.onStage, this.onUnstage, this.onDiscard});
final Map<String, Object?> entry;
final void Function(String path)? onStage;
@@ -371,39 +292,15 @@ class _GitFileRow extends StatelessWidget {
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
child: Row(
children: [
ClideText(
_stateIndicator(state),
fontSize: clideFontCaption,
color: _stateColor(state, tokens),
),
ClideText(_stateIndicator(state), fontSize: clideFontCaption, color: _stateColor(state, tokens)),
const SizedBox(width: 6),
Expanded(
child: ClideText(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
),
if (hovered) ...[
if (onStage != null)
_SmallAction(
label: '+',
semanticsLabel: 'stage $name',
onTap: () => onStage!(path),
),
if (onUnstage != null)
_SmallAction(
label: '-',
semanticsLabel: 'unstage $name',
onTap: () => onUnstage!(path),
),
if (onDiscard != null)
_SmallAction(
label: 'x',
semanticsLabel: 'discard changes to $name',
onTap: () => onDiscard!(path),
),
if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
],
],
),
@@ -447,11 +344,7 @@ class _GitFileRow extends StatelessWidget {
}
class _SmallAction extends StatelessWidget {
const _SmallAction({
required this.label,
required this.onTap,
this.semanticsLabel,
});
const _SmallAction({required this.label, required this.onTap, this.semanticsLabel});
final String label;
final String? semanticsLabel;
@@ -467,11 +360,7 @@ class _SmallAction extends StatelessWidget {
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText(
label,
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
),
);
@@ -479,11 +368,7 @@ class _SmallAction extends StatelessWidget {
}
class _DiscardConfirmDialog extends StatelessWidget {
const _DiscardConfirmDialog({
required this.path,
required this.onConfirm,
required this.onCancel,
});
const _DiscardConfirmDialog({required this.path, required this.onConfirm, required this.onCancel});
final String path;
final VoidCallback onConfirm;
@@ -505,30 +390,16 @@ class _DiscardConfirmDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
'Discard changes?',
color: tokens.globalForeground,
),
ClideText('Discard changes?', color: tokens.globalForeground),
const SizedBox(height: 8),
ClideText(
'Unstaged changes to $name will be permanently lost.',
fontSize: clideFontCaption,
color: tokens.statusError,
),
ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: 'Cancel',
variant: ClideButtonVariant.subtle,
onPressed: onCancel,
),
ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
const SizedBox(width: 8),
ClideButton(
label: 'Discard',
onPressed: onConfirm,
),
ClideButton(label: 'Discard', onPressed: onConfirm),
],
),
],
+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),
+6 -6
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: {
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,
+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),
],
@@ -28,12 +28,7 @@ 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 => [
@@ -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),
+18 -8
View File
@@ -68,13 +68,20 @@ class MenuBarExtension extends ClideExtension {
/// 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: [
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 [
],
),
TopMenu(
title: 'View',
mnemonic: 0,
nodes: const [
MenuCommandItem('view.zoomIn'),
MenuCommandItem('view.zoomOut'),
MenuCommandItem('view.zoomReset'),
@@ -85,8 +92,11 @@ List<TopMenu> buildClideMenuTree() => [
MenuCommandItem('panel.focusMode'),
MenuSeparator(),
MenuAutoFill('view.'),
]),
TopMenu(title: 'Help', mnemonic: 0, nodes: const [
MenuCommandItem('help.about', fallbackTitle: 'About clide'),
]),
];
],
),
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),
),
),
),
+2 -9
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)
@@ -142,9 +137,7 @@ 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)),
],
MenuAutoFill(:final prefix) => [for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command))],
};
return [
+12 -20
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(
+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,
),
),
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',
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'),
],
),
),
@@ -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(
+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) {
+2 -11
View File
@@ -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),
),
),
);
@@ -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),
),
],
),
@@ -86,7 +86,9 @@ 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: {
final r = await ipc.request(
'search.replace',
args: {
'pattern': pattern,
'regex': regex,
'ignoreCase': ignoreCase,
@@ -94,7 +96,8 @@ class FindInFilesController extends ChangeNotifier {
'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;
+50 -64
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();
}
@@ -139,13 +138,15 @@ class _SearchPanelViewState extends State<SearchPanelView> {
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,
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),
@@ -154,10 +155,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
hint: 'files to include (e.g. *.dart)',
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.include = v),
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: [
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(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),
),
),
+8 -18
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: {
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)],
),
),
);
+5 -26
View File
@@ -23,21 +23,11 @@ class ThemePickerExtension extends ClideExtension {
// 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,
),
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(),
),
StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
];
Future<IpcResponse> _pick(List<String> args) async {
@@ -45,21 +35,10 @@ class ThemePickerExtension extends ClideExtension {
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)),
],
),
);
+1 -7
View File
@@ -1,13 +1,7 @@
import 'dart:ui' show Color;
class TicketTypeColors {
const TicketTypeColors({
required this.initiative,
required this.epic,
required this.story,
required this.task,
required this.bug,
});
const TicketTypeColors({required this.initiative, required this.epic, required this.story, required this.task, required this.bug});
final Color initiative;
final Color epic;
+23 -17
View File
@@ -73,10 +73,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
return ClidePaneChrome(
title: d.id,
subtitle: d.title,
leading: ReaderPinButton(
pinned: _nav?.hasPinned ?? false,
onTap: _onPin,
),
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _onPin),
trailing: [
ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false,
@@ -104,13 +101,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
const SizedBox(height: 16),
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
const SizedBox(height: 6),
for (var i = 0; i < d.parents.length; i++)
_CompactCard(
data: d.parents[i],
tokens: tokens,
typeColors: typeColors,
indent: i,
),
for (var i = 0; i < d.parents.length; i++) _CompactCard(data: d.parents[i], tokens: tokens, typeColors: typeColors, indent: i),
],
if (d.decisions.isNotEmpty) ...[
const SizedBox(height: 16),
@@ -150,7 +141,11 @@ class _TicketHeader extends StatelessWidget {
children: [
ClideTooltip(
message: detail.type ?? 'task',
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(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
@@ -188,10 +183,13 @@ class _StatusControls extends StatelessWidget {
onTap: detail.status == s
? null
: () async {
final resp = await controller.ipc.request('pql.tickets.status', args: {
final resp = await controller.ipc.request(
'pql.tickets.status',
args: {
'ids': [detail.id],
'status': s
});
'status': s,
},
);
if (resp.ok) {
controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id});
await controller.load(detail.id);
@@ -266,7 +264,11 @@ class _CompactCard extends StatelessWidget {
),
child: Row(
children: [
Container(width: 6, height: 6, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
@@ -312,7 +314,11 @@ class _DecisionRefCard extends StatelessWidget {
children: [
Row(
children: [
Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily),
const Spacer(),
+22 -28
View File
@@ -26,13 +26,7 @@ class _TicketsViewState extends State<TicketsView> {
/// ticket type; all on by default. An empty set never persists — toggling off
/// the last one snaps all back on, so the list is never mysteriously blank.
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
static const _typeOrder = [
('initiative', 'Initiative'),
('epic', 'Epic'),
('story', 'Story'),
('task', 'Task'),
('bug', 'Bug'),
];
static const _typeOrder = [('initiative', 'Initiative'), ('epic', 'Epic'), ('story', 'Story'), ('task', 'Task'), ('bug', 'Bug')];
final Set<String> _enabledTypes = {..._allTypes};
StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub;
@@ -107,9 +101,11 @@ class _TicketsViewState extends State<TicketsView> {
_focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus);
_changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) {
final id = msg.data['id'] as String?;
unawaited(_refresh().then((_) {
unawaited(
_refresh().then((_) {
if (id != null && mounted) _scrollToFocused(id);
}));
}),
);
});
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into
@@ -208,7 +204,9 @@ class _TicketsViewState extends State<TicketsView> {
children: [
Row(
children: [
Expanded(child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
Expanded(
child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
@@ -277,14 +275,7 @@ class _TicketsViewState extends State<TicketsView> {
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
/// owns both so Flutter disambiguates single vs double.
class _TypeChip extends StatelessWidget {
const _TypeChip({
required this.label,
required this.color,
required this.active,
required this.onToggle,
required this.onSolo,
required this.tokens,
});
const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
final String label;
final Color color;
@@ -317,7 +308,11 @@ class _TypeChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
],
@@ -412,15 +407,17 @@ class _TicketCard extends StatelessWidget {
),
const SizedBox(height: 4),
ClideText(entry.title, fontSize: clideFontCaption),
if (statusLabel != null) ...[
const SizedBox(height: 6),
_StatusBadge(label: statusLabel, tokens: tokens, status: entry.status),
],
if (statusLabel != null) ...[const SizedBox(height: 6), _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status)],
],
),
// Hover affordance (T-327): hand the full ticket to the focused
// Claude pane via the message bus.
if (hovered) Positioned(top: 0, right: 0, child: _PickUpAction(id: entry.id, tokens: tokens)),
if (hovered)
Positioned(
top: 0,
right: 0,
child: _PickUpAction(id: entry.id, tokens: tokens),
),
],
),
),
@@ -482,10 +479,7 @@ class _StatusBadge extends StatelessWidget {
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(0x30),
borderRadius: BorderRadius.circular(3),
),
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
);
}
+1 -6
View File
@@ -20,12 +20,7 @@ class VimModeIndicator extends StatelessWidget {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClideText(
'-- ${service.mode.label} --',
fontFamily: clideMonoFamily,
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
child: ClideText('-- ${service.mode.label} --', fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.statusBarForeground),
);
},
);
+1 -4
View File
@@ -26,10 +26,7 @@ class WelcomeExtension extends ClideExtension {
id: 'workspace.open-project',
command: 'workspace.open-project',
title: 'Workspace: Open project…',
run: (_) async => IpcResponse.ok(
id: '',
data: const {'note': 'project picker lands in a later tier'},
),
run: (_) async => IpcResponse.ok(id: '', data: const {'note': 'project picker lands in a later tier'}),
),
];
}
+53 -72
View File
@@ -40,15 +40,16 @@ class WelcomeView extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
Expanded(
child: _StartColumn(tokens: tokens, kernel: kernel),
),
const SizedBox(width: 56),
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
Expanded(
child: _RecentColumn(tokens: tokens, kernel: kernel),
),
],
),
if (showTips) ...[
const SizedBox(height: 48),
_TipsCard(tokens: tokens),
],
if (showTips) ...[const SizedBox(height: 48), _TipsCard(tokens: tokens)],
],
),
),
@@ -119,7 +120,9 @@ class _TipsCard extends StatelessWidget {
Expanded(
child: Row(
children: [
Expanded(child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
Expanded(
child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted),
),
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily),
],
),
@@ -166,27 +169,9 @@ class _StartColumn extends StatelessWidget {
children: [
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 20),
_ActionRow(
icon: PhosphorIcons.byName('folder'),
label: 'Open folder…',
shortcut: '⌘O',
tokens: tokens,
onTap: () => _openFolder(context),
),
_ActionRow(
icon: PhosphorIcons.byName('git-branch'),
label: 'Clone from git…',
shortcut: '⌘G',
tokens: tokens,
onTap: () {},
),
_ActionRow(
icon: PhosphorIcons.byName('chat-circle'),
label: 'Start a Claude session',
shortcut: '⌘C',
tokens: tokens,
onTap: () {},
),
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
_ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
_ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
],
);
}
@@ -199,10 +184,7 @@ class _StartColumn extends StatelessWidget {
if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
path: picked,
onDismiss: () => dismiss(),
));
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
}
}
return;
@@ -239,15 +221,14 @@ class _ActionRow extends StatelessWidget {
onTap: onTap,
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: Row(
children: [
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
const SizedBox(width: 14),
Expanded(child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground)),
Expanded(
child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground),
),
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
],
),
@@ -296,12 +277,7 @@ class _RecentColumn extends StatelessWidget {
}
class _RecentRow extends StatelessWidget {
const _RecentRow({
required this.project,
required this.tokens,
required this.onTap,
required this.onToggleSticky,
});
const _RecentRow({required this.project, required this.tokens, required this.onTap, required this.onToggleSticky});
final RecentProject project;
final SurfaceTokens tokens;
final VoidCallback onTap;
@@ -313,10 +289,7 @@ class _RecentRow extends StatelessWidget {
onTap: onTap,
builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: Row(
children: [
Expanded(
@@ -328,27 +301,36 @@ class _RecentRow extends StatelessWidget {
Row(
children: [
Flexible(
child: ClideText(project.relativePath,
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
child: ClideText(
project.relativePath,
muted: true,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (project.branch != null) ...[
ClideText(' · ', muted: true, fontSize: clideFontMeta),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted),
const SizedBox(width: 3),
Flexible(
child: ClideText(project.branch!,
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
child: ClideText(
project.branch!,
muted: true,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
),
),
_StickyToggle(
key: ValueKey('welcome.sticky.${project.path}'),
sticky: project.startupSticky,
tokens: tokens,
onTap: onToggleSticky,
),
_StickyToggle(key: ValueKey('welcome.sticky.${project.path}'), sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
const SizedBox(width: 12),
ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta),
],
@@ -420,8 +402,12 @@ class _StatusLine extends StatelessWidget {
else if (tc.allOk)
ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
else
ClideText(tc.missing.map((t) => '$t not found').join(' · '),
fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusWarning),
ClideText(
tc.missing.map((t) => '$t not found').join(' · '),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: tokens.statusWarning,
),
ClideText(' · ', muted: true, fontSize: clideFontSmall),
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
],
@@ -526,16 +512,17 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
controller: _controller,
focusNode: _focus,
style: TextStyle(
color: tokens.globalForeground, fontSize: clideFontCaption, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
color: tokens.globalForeground,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
),
cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall),
],
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -575,17 +562,11 @@ class _NotARepoDialog extends StatelessWidget {
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8),
const ClideText(
'A clide project root requires a git repository.',
muted: true,
fontSize: clideFontMeta,
),
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: clideFontMeta),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
),
],
),
+6 -42
View File
@@ -56,13 +56,7 @@ class TabContribution extends ContributionPoint {
/// A status-bar item. Order is determined by [priority] within each
/// alignment group; negative priorities float left, positive right.
class StatusItemContribution extends ContributionPoint {
const StatusItemContribution({
required super.id,
required this.build,
this.priority = 0,
this.listenable,
this.flex = 0,
});
const StatusItemContribution({required super.id, required this.build, this.priority = 0, this.listenable, this.flex = 0});
@override
SlotId get slot => Slots.statusbar;
@@ -80,14 +74,7 @@ class StatusItemContribution extends ContributionPoint {
/// A button in the main toolbar.
class ToolbarButtonContribution extends ContributionPoint {
const ToolbarButtonContribution({
required super.id,
required this.label,
required this.onPressed,
this.icon,
this.tooltip,
this.priority = 0,
});
const ToolbarButtonContribution({required super.id, required this.label, required this.onPressed, this.icon, this.tooltip, this.priority = 0});
@override
SlotId get slot => Slots.toolbar;
@@ -101,14 +88,7 @@ class ToolbarButtonContribution extends ContributionPoint {
/// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint {
const CommandContribution({
required super.id,
required this.command,
required this.run,
this.title,
this.defaultBinding,
this.bindingWhen,
});
const CommandContribution({required super.id, required this.command, required this.run, this.title, this.defaultBinding, this.bindingWhen});
final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged"
@@ -124,12 +104,7 @@ class CommandContribution extends ContributionPoint {
/// Registers an item in the OS tray / menu-bar.
class TrayItemContribution extends ContributionPoint {
const TrayItemContribution({
required super.id,
required this.label,
required this.onSelected,
this.priority = 0,
});
const TrayItemContribution({required super.id, required this.label, required this.onSelected, this.priority = 0});
@override
SlotId get slot => Slots.tray;
@@ -141,11 +116,7 @@ class TrayItemContribution extends ContributionPoint {
/// A named layout arrangement. One "classic" preset ships with
/// `builtin.default-layout`; other presets can be contributed.
class LayoutPresetContribution extends ContributionPoint {
const LayoutPresetContribution({
required super.id,
required this.displayName,
required this.slots,
});
const LayoutPresetContribution({required super.id, required this.displayName, required this.slots});
final String displayName;
final List<LayoutSlot> slots;
@@ -154,14 +125,7 @@ class LayoutPresetContribution extends ContributionPoint {
/// One slot in a [LayoutPresetContribution]. Describes where the slot
/// appears and its initial size/visibility.
class LayoutSlot {
const LayoutSlot({
required this.slot,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
this.visible = true,
});
const LayoutSlot({required this.slot, required this.position, this.defaultSize, this.minSize, this.maxSize, this.visible = true});
final SlotId slot;
final SlotPosition position;
+2 -11
View File
@@ -98,15 +98,6 @@ extension ClideExtensionContextI18n on ClideExtensionContext {
String t(String key, {String? placeholder}) => i18n.string(key, namespace: id, placeholder: placeholder);
/// [t] with interpolation replacers.
String tr(
String key, {
String? placeholder,
List<I18nReplacer> replacers = const [],
}) =>
i18n.interpolated(
key,
namespace: id,
placeholder: placeholder,
replacers: replacers,
);
String tr(String key, {String? placeholder, List<I18nReplacer> replacers = const []}) =>
i18n.interpolated(key, namespace: id, placeholder: placeholder, replacers: replacers);
}
+1 -8
View File
@@ -45,14 +45,7 @@ class ExtensionManifest {
if (d is String) deps.add(d);
}
}
return ExtensionManifest(
id: id,
title: title,
version: version,
dependsOn: deps,
entry: entry,
schemaVersion: schemaVersion,
);
return ExtensionManifest(id: id, title: title, version: version, dependsOn: deps, entry: entry, schemaVersion: schemaVersion);
}
static Future<ExtensionManifest> fromFile(File f) async => ExtensionManifest.fromYamlString(await f.readAsString());
+8 -29
View File
@@ -54,13 +54,7 @@ class CliInstallStatus {
/// Result of [CliInstaller.install].
class CliInstallResult {
const CliInstallResult({
required this.ok,
required this.message,
this.installedPath,
this.onPath = true,
this.fromDevTree = false,
});
const CliInstallResult({required this.ok, required this.message, this.installedPath, this.onPath = true, this.fromDevTree = false});
final bool ok;
final String message;
@@ -80,12 +74,8 @@ class CliInstallResult {
/// environment, candidate client locations, the target dir) is injectable so
/// the logic is unit-testable without a real install.
class CliInstaller {
CliInstaller({
required this.resolvedExecutable,
Map<String, String>? env,
List<String>? bundledClientCandidates,
String? installDir,
}) : env = env ?? Platform.environment,
CliInstaller({required this.resolvedExecutable, Map<String, String>? env, List<String>? bundledClientCandidates, String? installDir})
: env = env ?? Platform.environment,
bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment),
installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment);
@@ -132,7 +122,8 @@ class CliInstaller {
if (src == null) {
return const CliInstallResult(
ok: false,
message: 'No bundled clide client found to install. Build with '
message:
'No bundled clide client found to install. Build with '
'`make build` so the C client ships inside the app bundle.',
);
}
@@ -203,11 +194,7 @@ class CliInstaller {
return null;
}
String _expandedPath() => expandedPath(
env['PATH'] ?? '',
macOS: Platform.isMacOS,
home: env['HOME'] ?? '',
);
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
@@ -217,10 +204,7 @@ class CliInstaller {
/// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path;
return [
if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!,
'$exeDir/clide-cli',
];
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli'];
}
}
@@ -239,12 +223,7 @@ bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path);
/// platform-parameterized function so both branches are testable off-platform.
String expandedPath(String base, {required bool macOS, String home = ''}) {
if (!macOS) return base;
final extras = <String>[
if (home.isNotEmpty) '$home/.local/bin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/bin',
];
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
+1 -4
View File
@@ -14,10 +14,7 @@ class ClideClipboard {
final int historyLimit;
final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>(
T value, {
String Function(T)? toPlain,
}) async {
Future<void> write<T extends Object>(T value, {String Function(T)? toPlain}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast();
+1 -3
View File
@@ -5,9 +5,7 @@ import 'package:flutter/services.dart';
/// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable
class Keybinding {
Keybinding({required Set<String> modifiers, required String key})
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
Keybinding({required Set<String> modifiers, required String key}) : modifiers = _canonModifiers(modifiers), key = key.toLowerCase();
final List<String> modifiers;
final String key;
+2 -9
View File
@@ -17,19 +17,12 @@ class CommandRegistry extends ChangeNotifier {
Iterable<CommandContribution> get all => _byCommand.values;
CommandContribution? get(String command) => _byCommand[command];
Future<IpcResponse> execute(
String command, {
List<String> args = const [],
}) async {
Future<IpcResponse> execute(String command, {List<String> args = const []}) async {
final c = _byCommand[command];
if (c == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such command: $command',
),
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such command: $command'),
);
}
return c.run(args);
+3 -14
View File
@@ -2,10 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function(
BuildContext context,
void Function([T? result]) dismiss,
);
typedef DialogBuilder<T> = Widget Function(BuildContext context, void Function([T? result]) dismiss);
/// Single-at-a-time modal router.
///
@@ -70,12 +67,7 @@ class _Queued {
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget {
const DialogHost({
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
const DialogHost({super.key, required this.router, required this.child, this.backdropColor = const Color(0xC0000000)});
final DialogRouter router;
final Widget child;
@@ -98,10 +90,7 @@ class DialogHost extends StatelessWidget {
child: ColoredBox(
color: backdropColor,
child: Center(
child: GestureDetector(
onTap: () {},
child: b(ctx, router.dismiss),
),
child: GestureDetector(onTap: () {}, child: b(ctx, router.dismiss)),
),
),
),
+1 -5
View File
@@ -1,11 +1,7 @@
import 'dart:async';
class Message {
Message({
required this.publisher,
required this.channel,
required this.data,
}) : timestamp = DateTime.now();
Message({required this.publisher, required this.channel, required this.data}) : timestamp = DateTime.now();
final String publisher;
final String channel;
+3 -18
View File
@@ -13,13 +13,7 @@ class ClideEventEnvelope {
final ClideEvent event;
final DateTime timestamp;
Map<String, Object?> toJson() => {
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
Map<String, Object?> toJson() => {'v': 1, 'subsystem': event.subsystem, 'kind': event.kind, 'ts': timestamp.toIso8601String(), 'data': event.payload()};
}
class DaemonConnectionChanged extends ClideEvent {
@@ -89,12 +83,7 @@ class ExtensionDeactivated extends ClideEvent {
/// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent {
const DaemonEvent({
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
const DaemonEvent({required this.subsystem, required this.kind, required this.data, required this.ts});
@override
final String subsystem;
@@ -160,11 +149,7 @@ class TeamMemberJoined extends ClideEvent {
/// A Claude Code tmux teammate's pane went away (it exited or the team
/// dissolved) — T-139.
class TeamMemberLeft extends ClideEvent {
const TeamMemberLeft({
required this.team,
required this.agentId,
required this.paneId,
});
const TeamMemberLeft({required this.team, required this.agentId, required this.paneId});
final String team;
final String agentId;

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