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

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

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

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

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

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

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

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

No runtime behaviour change; `make test` green.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:44:17 +02:00
451 changed files with 7822 additions and 12876 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.44.1"
}
+11
View File
@@ -3514,3 +3514,14 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', '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 ('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 ('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 ('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 ('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 ('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. 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); This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+53
View File
@@ -16,6 +16,59 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
## [2.3.3] — 2026-06-11
### Fixed
- **Ticket/decision sidebars load on first open after a desktop launch.** A
desktop launch starts in HOME (not a git repo), and the daemon booted its
pql/git/files workspace there — so pql ran in HOME, hit a stale
`~/.pql/pql.db`, and the sidebars showed "pql … failed" until the project was
reopened (a manual refresh worked once the workspace had swapped to the repo).
The daemon now boots at the last opened project when the launch directory
isn't itself a repo, so pql targets the real workspace from the first request.
(T-352)
### Changed
- **Minimum toolchain raised to honest values.** `pubspec.yaml` now declares
Flutter `>=3.35.0` / Dart `>=3.9.0` (was `3.19.0` / `3.5.0`) — the real
minimums our deps already required (`alchemist` needs Flutter 3.32; Dart 3.9
ships in Flutter 3.35). The exact build toolchain is pinned in `.fvmrc`
(Flutter 3.44.1). Moving to the Dart 3.9 language level reformatted the tree
to the new "tall" style and adopted two new lints (`unnecessary_underscores`,
`use_null_aware_elements`). (T-353)
### Security
- **Dependency audit + refresh.** Reviewed every pinned and transitive
dependency against the GitHub Advisory Database / OSV (Pub ecosystem) — no
advisory affects any current or candidate version. Refreshed the safe pins:
`ffi` 2.1.3→2.2.0, `jovial_svg` 1.1.26→1.1.30 (pulls `jovial_misc` 0.10.0 +
`xml` 7.0.1), `mocktail` 1.0.4→1.0.5, and `markdown` 7.2.2→7.3.1 (unblocked by
the Dart 3.9 floor below). Deliberately held with reasons in `pubspec.yaml`:
`alchemist` (0.13 golden churn), `test` (Flutter-SDK locked). (T-353)
- **Supply-chain gate (`make security`).** A new `security` target runs
`osv-scanner` over `pubspec.lock` and fails if any resolved dependency has a
known advisory — a hard gate on top of `dart pub`'s passive, non-failing
advisory print. Intended for the CI PR-merge pipeline (kept out of
`push-check` so dev machines don't need the scanner installed); run locally
any time with `make security`. (T-353)
## [2.3.2] — 2026-06-11
### Fixed
- **Ticket and decision sidebars reliably load on first open (real fix).** The
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
is up; with sticky-startup the project opens during boot, before the panes
mount, so they never saw the event. The underlying cause was a race: the boot
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
ran concurrently, and the late-finishing boot swap could clobber the repo
bind — leaving the daemon's pql/git/files pointed at the launch directory
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
serialized so the repo bind always wins. (T-352)
## [2.3.1] — 2026-06-10 ## [2.3.1] — 2026-06-10
### Fixed ### Fixed
+6 -2
View File
@@ -309,8 +309,8 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
# -- security ------------------------------------------------------------- # -- security -------------------------------------------------------------
.PHONY: security .PHONY: security
security: ## Dart advisory review. security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps." ci/osv_scan.sh
# -- pre-push gate -------------------------------------------------------- # -- pre-push gate --------------------------------------------------------
@@ -319,6 +319,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
pql decisions validate pql decisions validate
.PHONY: push-check .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. 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 .PHONY: push-check-full
+5 -5
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info` # Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump # (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead. # pubspec instead.
version: "2.3.1" version: "2.3.3"
homepage: https://github.com/postmeridiem/clide homepage: https://github.com/postmeridiem/clide
license: MIT license: MIT
license_file: assets/LICENSE license_file: assets/LICENSE
@@ -116,7 +116,7 @@ dependencies:
- name: ffi - name: ffi
kind: dart-package kind: dart-package
version: "2.1.3" version: "2.2.0"
homepage: https://pub.dev/packages/ffi homepage: https://pub.dev/packages/ffi
license: BSD-3-Clause license: BSD-3-Clause
purpose: >- purpose: >-
@@ -171,7 +171,7 @@ dependencies:
- name: jovial_svg - name: jovial_svg
kind: dart-package kind: dart-package
version: "1.1.26" version: "1.1.30"
homepage: https://pub.dev/packages/jovial_svg homepage: https://pub.dev/packages/jovial_svg
license: BSD-3-Clause license: BSD-3-Clause
purpose: >- purpose: >-
@@ -182,7 +182,7 @@ dependencies:
- name: markdown - name: markdown
kind: dart-package kind: dart-package
version: "7.2.2" version: "7.3.1"
homepage: https://pub.dev/packages/markdown homepage: https://pub.dev/packages/markdown
license: BSD-3-Clause license: BSD-3-Clause
purpose: >- purpose: >-
@@ -207,7 +207,7 @@ dependencies:
dev_dependencies: dev_dependencies:
- name: mocktail - name: mocktail
kind: dart-package kind: dart-package
version: "1.0.4" version: "1.0.5"
homepage: https://pub.dev/packages/mocktail homepage: https://pub.dev/packages/mocktail
license: MIT license: MIT
purpose: >- 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; import 'dart:ui' show Color;
class ClideTheme { class ClideTheme {
const ClideTheme({ const ClideTheme({required this.name, required this.dark, required this.subtitle, required this.palette, required this.syntax});
required this.name,
required this.dark,
required this.subtitle,
required this.palette,
required this.syntax,
});
final String name; final String name;
final bool dark; final bool dark;
final String subtitle; final String subtitle;
+3 -13
View File
@@ -33,23 +33,13 @@ void main() {
tester.view.resetDevicePixelRatio(); tester.view.resetDevicePixelRatio();
}); });
final themes = [ final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot( final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_intg_'), appDir: await Directory.systemTemp.createTemp('clide_intg_'),
bundledThemes: themes, bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle), i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [ preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.default-layout'],
'builtin.welcome', daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false, autoStartDaemonClient: false,
); );
services.extensions services.extensions
+3 -12
View File
@@ -25,22 +25,13 @@ void main() {
tester.view.resetDevicePixelRatio(); tester.view.resetDevicePixelRatio();
}); });
final themes = [ final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot( final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_lc_'), appDir: await Directory.systemTemp.createTemp('clide_lc_'),
bundledThemes: themes, bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle), i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [ preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.default-layout'],
'builtin.welcome', daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
'builtin.ipc-status',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false, autoStartDaemonClient: false,
); );
services.extensions services.extensions
+3 -12
View File
@@ -16,22 +16,13 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async { testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
final themes = [ final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot( final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'), appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
bundledThemes: themes, bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle), i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [ preloadNamespaces: const ['builtin.welcome', 'builtin.theme-picker', 'builtin.default-layout'],
'builtin.welcome', daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false, autoStartDaemonClient: false,
); );
services.extensions services.extensions
+56 -110
View File
@@ -38,10 +38,7 @@ class _AppRoot extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: clideName, title: clideName,
color: const Color(0xFF000000), color: const Color(0xFF000000),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>( pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(settings: settings, pageBuilder: (ctx, _, _) => builder(ctx)),
settings: settings,
pageBuilder: (ctx, _, __) => builder(ctx),
),
home: _RootShell(services: services), home: _RootShell(services: services),
); );
} }
@@ -240,35 +237,19 @@ class RootLayout extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
if (sidebarVisible && sidebarCollapsed) if (sidebarVisible && sidebarCollapsed)
ClideSpine( ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
label: _sidebarSpineLabel(kernel),
side: SpineSide.left,
onExpand: () => a.setCollapsed(Slots.sidebar, false),
)
else if (sidebarVisible) ...[ else if (sidebarVisible) ...[
SizedBox( SizedBox(
width: sidebarSize, width: sidebarSize,
child: SlotHost(slot: Slots.sidebar), child: SlotHost(slot: Slots.sidebar),
), ),
DragResizeHandle( DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
arrangement: a,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
], ],
const Expanded(child: SlotHost(slot: Slots.workspace)), const Expanded(child: SlotHost(slot: Slots.workspace)),
if (contextVisible && contextCollapsed) if (contextVisible && contextCollapsed)
ClideSpine( ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
label: 'context',
side: SpineSide.right,
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
)
else if (contextVisible) ...[ else if (contextVisible) ...[
DragResizeHandle( DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
arrangement: a,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
SizedBox( SizedBox(
width: contextSize, width: contextSize,
child: SlotHost(slot: Slots.contextPanel), child: SlotHost(slot: Slots.contextPanel),
@@ -281,14 +262,18 @@ class RootLayout extends StatelessWidget {
SizedBox( SizedBox(
height: dockHeight, height: dockHeight,
child: DecoratedBox( 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), child: SlotHost(slot: Slots.dock),
), ),
), ),
if (statusVisible) if (statusVisible)
Container( Container(
height: statusHeight, 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( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -296,12 +281,18 @@ class RootLayout extends StatelessWidget {
// children) so they never shift when a pane collapses (T-294). // children) so they never shift when a pane collapses (T-294).
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible), StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
if (sidebarVisible && !sidebarCollapsed) if (sidebarVisible && !sidebarCollapsed)
SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar)) SizedBox(
width: sidebarSize,
child: _BottomRail(slot: Slots.sidebar),
)
else if (sidebarVisible && sidebarCollapsed) else if (sidebarVisible && sidebarCollapsed)
const SizedBox(width: ClideSpine.width), const SizedBox(width: ClideSpine.width),
const Expanded(child: StatusbarHost()), const Expanded(child: StatusbarHost()),
if (contextVisible && !contextCollapsed) if (contextVisible && !contextCollapsed)
SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel)) SizedBox(
width: contextSize,
child: _BottomRail(slot: Slots.contextPanel),
)
else if (contextVisible && contextCollapsed) else if (contextVisible && contextCollapsed)
const SizedBox(width: ClideSpine.width), const SizedBox(width: ClideSpine.width),
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible), StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
@@ -392,11 +383,13 @@ class _RightHatContent extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (kIsWeb) return const SizedBox.shrink(); if (kIsWeb) return const SizedBox.shrink();
if (!kIsWeb && Platform.isMacOS) 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(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens), _WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true), _WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
]); ],
);
} }
} }
@@ -417,11 +410,7 @@ class _WinBtn extends StatelessWidget {
height: hatHeight, height: hatHeight,
color: hovered ? hoverBg : null, color: hovered ? hoverBg : null,
alignment: Alignment.center, alignment: Alignment.center,
child: ClideIcon( child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
icon,
size: 14,
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
),
), ),
); );
} }
@@ -544,26 +533,29 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: filtered.length, itemCount: filtered.length,
itemBuilder: (ctx, i) => _RecentProjectRow( itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
project: filtered[i],
tokens: tokens,
onTap: () => _openProject(filtered[i].path),
),
), ),
), ),
] else ] else
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)), const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
Container( Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))), decoration: BoxDecoration(
border: Border(top: BorderSide(color: tokens.dividerColor)),
),
child: Column( child: Column(
children: [ children: [
_ActionRow( _ActionRow(
label: 'Open Local Project', label: 'Open Local Project',
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O', shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
tokens: tokens, tokens: tokens,
onTap: () => _runFileCommand('file.openFolder')), onTap: () => _runFileCommand('file.openFolder'),
),
_ActionRow( _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) if (widget.kernel.project.isOpen)
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')), _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 // Elide a long path instead of overflowing the row
// (matches the welcome recents row; T-160 discipline). // (matches the welcome recents row; T-160 discipline).
Flexible( Flexible(
child: ClideText(project.relativePath, child: ClideText(
muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis), project.relativePath,
muted: true,
fontSize: 12,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
ClideText(' · ', muted: true, fontSize: 12), ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted), ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
@@ -706,10 +704,7 @@ class _SlotHostState extends State<SlotHost> {
return Container(color: tokens.panelBackground); return Container(color: tokens.panelBackground);
} }
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id; final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
final active = tabs.firstWhere( final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
(t) => t.id == activeId,
orElse: () => tabs.first,
);
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId); 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; final tokens = ClideTheme.of(context).surface;
if (slot == Slots.sidebar) { if (slot == Slots.sidebar) {
return _SidebarSlot( return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
} }
if (slot == Slots.contextPanel) { if (slot == Slots.contextPanel) {
return _ContextSlot( return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
} }
if (slot == Slots.workspace) { if (slot == Slots.workspace) {
@@ -757,9 +742,7 @@ class _SlotBody extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
ClideTabBar( ClideTabBar(
items: [ items: [for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t))],
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
],
activeId: active.id, activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id), onSelect: (id) => kernel.panels.activateTab(slot, id),
), ),
@@ -774,21 +757,12 @@ class _SlotBody extends StatelessWidget {
final key = t.titleKey; final key = t.titleKey;
final ns = t.i18nNamespace; final ns = t.i18nNamespace;
if (key == null || ns == null) return t.title; if (key == null || ns == null) return t.title;
return ClideKernel.of(context).i18n.string( return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
key,
namespace: ns,
placeholder: t.title,
);
} }
} }
class _SidebarSlot extends StatelessWidget { class _SidebarSlot extends StatelessWidget {
const _SidebarSlot({ const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
required this.tabs,
required this.active,
required this.activeId,
required this.onSelect,
});
final List<TabContribution> tabs; final List<TabContribution> tabs;
final TabContribution active; final TabContribution active;
@@ -863,10 +837,7 @@ class _WorkspaceSlot extends StatelessWidget {
SizedBox( SizedBox(
height: topHeight, height: topHeight,
child: reveal != null child: reveal != null
? _RevealedTab( ? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
tab: reveal,
onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId),
)
: topTab.build(ctx), : topTab.build(ctx),
), ),
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight), _EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
@@ -903,12 +874,7 @@ class _RevealedTab extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ClideText( child: ClideText(_SlotBody._resolveTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
_SlotBody._resolveTitle(context, tab),
fontSize: clideFontCaption,
color: tokens.panelHeaderForeground,
maxLines: 1,
),
), ),
Semantics( Semantics(
button: true, button: true,
@@ -918,7 +884,7 @@ class _RevealedTab extends StatelessWidget {
child: ClideTappable( child: ClideTappable(
onTap: onClose, onTap: onClose,
tooltip: 'Close', tooltip: 'Close',
builder: (_, hovered, __) => Padding( builder: (_, hovered, _) => Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), 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 { class _ContextSlot extends StatelessWidget {
const _ContextSlot({ const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
required this.tabs,
required this.active,
required this.activeId,
required this.onSelect,
});
final List<TabContribution> tabs; final List<TabContribution> tabs;
final TabContribution active; final TabContribution active;
@@ -1042,12 +1003,7 @@ class _ContextSlot extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
return Container( return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
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( return Container(
color: tokens.chromeBackground, color: tokens.chromeBackground,
child: ClideIconRail( child: ClideIconRail(
items: [ items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: _SlotBody._resolveTitle(ctx, t))],
for (final t in tabs)
ClideIconRailItem(
id: t.id,
icon: _iconFor(slot, t),
tooltip: _SlotBody._resolveTitle(ctx, t),
),
],
activeId: activeId, activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id), onSelect: (id) => kernel.panels.activateTab(slot, id),
), ),
@@ -1209,10 +1158,7 @@ class _WelcomeOverlay extends StatelessWidget {
builder: (ctx, _) { builder: (ctx, _) {
if (kernel.project.isOpen) return const SizedBox.shrink(); if (kernel.project.isOpen) return const SizedBox.shrink();
final tokens = ClideTheme.of(ctx).surface; final tokens = ClideTheme.of(ctx).surface;
return ColoredBox( return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
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 /// 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 /// 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. /// 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>`. ' '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`), ' 'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), ' '`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. /// * `CLIDE_WORKSPACE` — the workspace root.
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide` /// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
/// is not already resolvable), otherwise left untouched. /// is not already resolvable), otherwise left untouched.
Map<String, String> agentEnvDelta({ Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
required String workspaceRoot, final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': 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) { if (clideCliDir != null && clideCliDir.isNotEmpty) {
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath'; 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 /// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes
/// `<dir>/clide`. Both are injected so the resolver is pure and testable. /// `<dir>/clide`. Both are injected so the resolver is pure and testable.
String? resolveClideCliDir({ String? resolveClideCliDir({required String? currentPath, required List<String> candidateDirs, required bool Function(String path) isExecutableFile}) {
required String? currentPath,
required List<String> candidateDirs,
required bool Function(String path) isExecutableFile,
}) {
if (currentPath != null) { if (currentPath != null) {
for (final dir in currentPath.split(':')) { for (final dir in currentPath.split(':')) {
if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null; if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null;
@@ -142,21 +131,9 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
'$workspaceRoot/native/${nativeClideDirName()}', '$workspaceRoot/native/${nativeClideDirName()}',
File(Platform.resolvedExecutable).parent.path, File(Platform.resolvedExecutable).parent.path,
]; ];
final cliDir = resolveClideCliDir( final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
currentPath: currentPath, final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
candidateDirs: candidates, return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
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) { bool _isExecutableFile(String path) {
+2 -10
View File
@@ -35,20 +35,12 @@ class ClaudeBanner extends StatelessWidget {
children: [ children: [
const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60), const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60),
const SizedBox(height: 18), const SizedBox(height: 18),
ClideText( ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
'Claude',
fontSize: clideFontDialogTitle,
color: claudeAccent,
fontWeight: FontWeight.w500,
),
const SizedBox(height: 2), const SizedBox(height: 2),
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily), ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
const SizedBox(height: 16), const SizedBox(height: 16),
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true), if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
if (statusLine != null) ...[ if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
const SizedBox(height: 2),
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
],
const SizedBox(height: 16), const SizedBox(height: 16),
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true), 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 /// Set the controller without it being treated as a user edit (so the
/// preview doesn't overwrite the persisted draft or exit navigation). /// 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); final tokens = _attachments.map((a) => a.pathToken);
if (text.trim().isEmpty && _attachments.isEmpty) return; if (text.trim().isEmpty && _attachments.isEmpty) return;
// Typed text first, then the attachment @path references. // Typed text first, then the attachment @path references.
final message = [ final message = [if (text.trim().isNotEmpty) text, ...tokens].join(' ');
if (text.trim().isNotEmpty) text,
...tokens,
].join(' ');
widget.onSubmit(message); widget.onSubmit(message);
_controller.clear(); _controller.clear();
setState(() => _attachments.clear()); setState(() => _attachments.clear());
@@ -454,11 +456,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
if (_attachments.isNotEmpty) if (_attachments.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Wrap( child: Wrap(spacing: 6, runSpacing: 6, children: [for (final a in _attachments) _chip(theme, a)]),
spacing: 6,
runSpacing: 6,
children: [for (final a in _attachments) _chip(theme, a)],
),
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
@@ -489,13 +487,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
}, },
child: Stack( child: Stack(
children: [ children: [
if (!hasText) if (!hasText) Positioned(left: 0, top: 0, right: 0, child: ClideText(widget.hint, muted: true, fontSize: clideFontBody)),
Positioned(
left: 0,
top: 0,
right: 0,
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
),
EditableText( EditableText(
controller: _controller, controller: _controller,
focusNode: _focus, focusNode: _focus,
@@ -516,10 +508,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
const SizedBox(width: 8), const SizedBox(width: 8),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: PermissionModeControl( child: PermissionModeControl(mode: widget.permissionMode!, onSelect: widget.onSetPermissionMode!),
mode: widget.permissionMode!,
onSelect: widget.onSetPermissionMode!,
),
), ),
], ],
], ],
@@ -548,12 +537,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
_chipLeading(theme, a), _chipLeading(theme, a),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
child: ClideText( child: ClideText(a.fileName, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
a.fileName,
fontSize: clideFontSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Semantics( Semantics(
+8 -43
View File
@@ -94,13 +94,7 @@ class ClaudePermissions {
/// + plugin + MCP), the skill names, the default model and permission mode. /// + plugin + MCP), the skill names, the default model and permission mode.
@immutable @immutable
class ClaudeProbe { class ClaudeProbe {
const ClaudeProbe({ const ClaudeProbe({required this.version, required this.slashCommands, required this.skills, this.model, this.permissionMode});
required this.version,
required this.slashCommands,
required this.skills,
this.model,
this.permissionMode,
});
final String version; final String version;
final List<String> slashCommands; final List<String> slashCommands;
@@ -400,10 +394,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Global first so that local entries, added later, win on collisions. /// Global first so that local entries, added later, win on collisions.
List<(ConfigScope, Directory)> _scopeDirs() { List<(ConfigScope, Directory)> _scopeDirs() {
final pd = _projectDir; final pd = _projectDir;
return [ return [(ConfigScope.global, _globalDir), if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude'))];
(ConfigScope.global, _globalDir),
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
];
} }
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async { Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
@@ -415,12 +406,7 @@ class ClaudeConfig extends ChangeNotifier {
final manifest = File('${entry.path}/SKILL.md'); final manifest = File('${entry.path}/SKILL.md');
if (!await manifest.exists()) continue; if (!await manifest.exists()) continue;
final fm = _parseFrontmatter(await manifest.readAsString()); final fm = _parseFrontmatter(await manifest.readAsString());
out.add(ClaudeSkill( out.add(ClaudeSkill(name: fm.name ?? _basename(entry.path), description: fm.description, scope: scope, path: manifest.path));
name: fm.name ?? _basename(entry.path),
description: fm.description,
scope: scope,
path: manifest.path,
));
} }
return out; return out;
} }
@@ -432,11 +418,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) { await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue; if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path); final base = _basename(entry.path);
out.add(ClaudeCommand( out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
} }
return out; return out;
} }
@@ -449,11 +431,7 @@ class ClaudeConfig extends ChangeNotifier {
await for (final entry in dir.list()) { await for (final entry in dir.list()) {
if (entry is! File || !entry.path.endsWith('.md')) continue; if (entry is! File || !entry.path.endsWith('.md')) continue;
final base = _basename(entry.path); final base = _basename(entry.path);
out.add(ClaudeAgent( out.add(ClaudeAgent(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
name: base.substring(0, base.length - 3),
scope: scope,
path: entry.path,
));
} }
return out; return out;
} }
@@ -472,11 +450,7 @@ class ClaudeConfig extends ChangeNotifier {
ClaudePermissions _permissionsOf(Map<String, Object?> settings) { ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
final p = settings['permissions']; final p = settings['permissions'];
if (p is! Map) return const ClaudePermissions(); if (p is! Map) return const ClaudePermissions();
return ClaudePermissions( return ClaudePermissions(allow: _stringList(p['allow']), deny: _stringList(p['deny']), ask: _stringList(p['ask']));
allow: _stringList(p['allow']),
deny: _stringList(p['deny']),
ask: _stringList(p['ask']),
);
} }
// ---- Watching ----------------------------------------------------------- // ---- Watching -----------------------------------------------------------
@@ -573,9 +547,7 @@ class ClaudeConfig extends ChangeNotifier {
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name. /// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) { static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
if (raw is! Map) return const []; if (raw is! Map) return const [];
return [ return [for (final key in raw.keys) ClaudeMcpServer(name: '$key')];
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
];
} }
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) { static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
@@ -608,14 +580,7 @@ Future<String?> _defaultVersionRunner() async {
Future<String?> _defaultInitProbe() async { Future<String?> _defaultInitProbe() async {
try { try {
final r = await Process.run('claude', [ final r = await Process.run('claude', ['-p', '.', '--no-session-persistence', '--output-format', 'stream-json', '--verbose']);
'-p',
'.',
'--no-session-persistence',
'--output-format',
'stream-json',
'--verbose',
]);
return r.stdout as String?; return r.stdout as String?;
} catch (_) { } catch (_) {
return null; return null;
+45 -122
View File
@@ -218,14 +218,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final managed = orch.byMemberName(memberName); final managed = orch.byMemberName(memberName);
if (managed == null) return; if (managed == null) return;
final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}'; final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}';
unawaited(orch.spawn(SpawnSpec( unawaited(
orch.spawn(
SpawnSpec(
id: forkId, id: forkId,
role: 'fork of $memberName', role: 'fork of $memberName',
// sessionId is a placeholder; real claude session id arrives via init. // sessionId is a placeholder; real claude session id arrives via init.
sessionId: forkId, sessionId: forkId,
cwd: managed.cwd, cwd: managed.cwd,
forkSourceSessionId: managed.sessionId, forkSourceSessionId: managed.sessionId,
))); ),
),
);
} }
Future<void> _refreshStats() async { Future<void> _refreshStats() async {
@@ -276,11 +280,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_MetaRow('sessions', '${latest.sessionCount}'), _MetaRow('sessions', '${latest.sessionCount}'),
_MetaRow('tool calls', '${latest.toolCallCount}'), _MetaRow('tool calls', '${latest.toolCallCount}'),
]), ]),
if (latest != null) if (latest != null) _MetaSection('LIFETIME', [_MetaRow('messages', '${_stats.lifetimeMessages}'), _MetaRow('sessions', '${_stats.lifetimeSessions}')]),
_MetaSection('LIFETIME', [
_MetaRow('messages', '${_stats.lifetimeMessages}'),
_MetaRow('sessions', '${_stats.lifetimeSessions}'),
]),
..._runtimeSection(tokens), ..._runtimeSection(tokens),
]; ];
if (sections.isEmpty) { if (sections.isEmpty) {
@@ -357,17 +357,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final broker = _orchestrator?.broker; final broker = _orchestrator?.broker;
if (chatModel != null && broker != null) { if (chatModel != null && broker != null) {
children.add(const SizedBox(height: 12)); children.add(const SizedBox(height: 12));
children.add(TeamChatSidebar( children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: _openChatPane));
model: chatModel,
broker: broker,
onPopOut: _openChatPane,
));
} }
return ListView( return ListView(padding: const EdgeInsets.all(12), children: children);
padding: const EdgeInsets.all(12),
children: children,
);
} }
Widget _taskSection(SurfaceTokens tokens) { Widget _taskSection(SurfaceTokens tokens) {
@@ -420,18 +413,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// Footer hint. // Footer hint.
Padding( Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: ClideText( child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
'expand a list to see all · click a skill/agent/command → opens its .md',
muted: true,
fontSize: clideFontSmall,
),
), ),
]; ];
return ListView( return ListView(padding: const EdgeInsets.all(12), children: children);
padding: const EdgeInsets.all(12),
children: children,
);
} }
/// One key→value row in the pinned SETTINGS table. /// 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) { List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
switch (section) { switch (section) {
case _ConfigSection.skills: case _ConfigSection.skills:
return [ return [for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path)];
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
];
case _ConfigSection.agents: case _ConfigSection.agents:
return [ return [for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path)];
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
];
case _ConfigSection.commands: case _ConfigSection.commands:
return [ return [for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path)];
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
];
case _ConfigSection.hooks: case _ConfigSection.hooks:
return [ return [
for (final hook in config.hooks) for (final hook in config.hooks)
@@ -540,11 +520,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
Widget _configFileRow(SurfaceTokens tokens, String name, String? path) { Widget _configFileRow(SurfaceTokens tokens, String name, String? path) {
final row = Padding( final row = Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2), padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText( child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
name,
fontSize: clideFontSmall,
color: path != null ? tokens.globalFocus : tokens.globalForeground,
),
); );
if (path == null) return row; if (path == null) return row;
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path}); void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
@@ -558,11 +534,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
onTap: openMarkdown, onTap: openMarkdown,
builder: (ctx, hovered, _) => Padding( builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2), padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText( child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
name,
fontSize: clideFontSmall,
color: hovered ? tokens.globalForeground : tokens.globalFocus,
),
), ),
), ),
); );
@@ -587,11 +559,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_ConfigPermKind.deny => kindDeny, _ConfigPermKind.deny => kindDeny,
}; };
final groups = [ final groups = [(_ConfigPermKind.allow, perms.allow), (_ConfigPermKind.ask, perms.ask), (_ConfigPermKind.deny, perms.deny)];
(_ConfigPermKind.allow, perms.allow),
(_ConfigPermKind.ask, perms.ask),
(_ConfigPermKind.deny, perms.deny),
];
final rows = <Widget>[]; final rows = <Widget>[];
for (final (kind, rules) in groups) { for (final (kind, rules) in groups) {
@@ -639,12 +607,15 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final children = <Widget>[]; final children = <Widget>[];
for (var i = 0; i < sections.length; i++) { for (var i = 0; i < sections.length; i++) {
final s = sections[i]; final s = sections[i];
children.add(Padding( children.add(
Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6), padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted), child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
)); ),
);
for (final r in s.rows) { for (final r in s.rows) {
children.add(Padding( children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch), padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -654,23 +625,17 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
child: ClideText(r.label, muted: true, fontSize: clideFontSmall), child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
), ),
Expanded( Expanded(
child: ClideText( child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
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. // T-183: accordion sections for the Config tab.
enum _ConfigSection { skills, agents, commands, hooks, permissions, mcpServers } enum _ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
@@ -788,7 +753,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
// Color dot // Color dot
Padding( Padding(
padding: const EdgeInsets.only(top: 3), 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), const SizedBox(width: 8),
// Name + status // Name + status
@@ -840,11 +809,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ClideText( child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
'Enable bypassPermissions? All tool calls will be auto-allowed.',
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
// Confirm // Confirm
@@ -889,14 +854,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
); );
} }
Widget _buildControls( Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
BuildContext context,
SurfaceTokens tokens,
ManagedSession managed,
bool isVisible,
bool isMuted,
bool isInjecting,
) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -990,12 +948,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
/// It is a custom painted label (no Material), consistent with the rendering /// It is a custom painted label (no Material), consistent with the rendering
/// stack rules (D-7, CLAUDE.md guardrails). /// stack rules (D-7, CLAUDE.md guardrails).
class _PermissionModeBadge extends StatelessWidget { class _PermissionModeBadge extends StatelessWidget {
const _PermissionModeBadge({ const _PermissionModeBadge({required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
required this.mode,
required this.tokens,
required this.onCycle,
required this.onBypass,
});
final String mode; final String mode;
final SurfaceTokens tokens; final SurfaceTokens tokens;
@@ -1012,7 +965,8 @@ class _PermissionModeBadge extends StatelessWidget {
final isBypass = mode == 'bypassPermissions'; final isBypass = mode == 'bypassPermissions';
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus; 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.'; 'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
return Padding( return Padding(
@@ -1046,11 +1000,7 @@ class _PermissionModeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1), border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
), ),
child: ClideText( child: ClideText(label, fontSize: 9, color: badgeColor),
label,
fontSize: 9,
color: badgeColor,
),
), ),
), ),
), ),
@@ -1060,12 +1010,7 @@ class _PermissionModeBadge extends StatelessWidget {
/// A single icon-button used in the roster row controls. /// A single icon-button used in the roster row controls.
class _IconButton extends StatelessWidget { class _IconButton extends StatelessWidget {
const _IconButton({ const _IconButton({required this.painter, required this.tooltip, required this.color, required this.onTap});
required this.painter,
required this.tooltip,
required this.color,
required this.onTap,
});
final ClideIconPainter painter; final ClideIconPainter painter;
final String tooltip; final String tooltip;
@@ -1096,11 +1041,7 @@ class _IconButton extends StatelessWidget {
/// Inline text input for injecting a message into a session (T-171). /// Inline text input for injecting a message into a session (T-171).
/// Submits on Enter; Cancel is handled by the parent via [_IconButton]. /// Submits on Enter; Cancel is handled by the parent via [_IconButton].
class _InjectTextField extends StatelessWidget { class _InjectTextField extends StatelessWidget {
const _InjectTextField({ const _InjectTextField({required this.controller, required this.tokens, required this.onSubmit});
required this.controller,
required this.tokens,
required this.onSubmit,
});
final TextEditingController controller; final TextEditingController controller;
final SurfaceTokens tokens; final SurfaceTokens tokens;
@@ -1119,12 +1060,7 @@ class _InjectTextField extends StatelessWidget {
child: EditableText( child: EditableText(
controller: controller, controller: controller,
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(), focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
style: TextStyle( style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
cursorColor: tokens.globalFocus, cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted, backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit, onSubmitted: onSubmit,
@@ -1139,11 +1075,7 @@ class _InjectTextField extends StatelessWidget {
/// One row in the TASKS section: status marker + title + owner + reassign. /// One row in the TASKS section: status marker + title + owner + reassign.
class _TaskRow extends StatelessWidget { class _TaskRow extends StatelessWidget {
const _TaskRow({ const _TaskRow({required this.task, required this.members, required this.broker});
required this.task,
required this.members,
required this.broker,
});
final TeamTask task; final TeamTask task;
final List<TeamMemberJoined> members; final List<TeamMemberJoined> members;
@@ -1240,18 +1172,9 @@ class _TabStrip extends StatelessWidget {
builder: (ctx, hovered, _) => Container( builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.only(bottom: 3), padding: const EdgeInsets.only(bottom: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
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,
), ),
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(); sub.cancel();
if (!c.isCompleted) c.complete(); 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(); sub.cancel();
}); },
);
if (!mounted) return; if (!mounted) return;
} }
return _spawn(); return _spawn();
@@ -267,13 +270,9 @@ class _ClaudePaneState extends State<ClaudePane> {
// assigned by `--fork-session` and arrives in the init event (T-172). // assigned by `--fork-session` and arrives in the init event (T-172).
_sessionId ??= freshSessionId(); _sessionId ??= freshSessionId();
try { try {
managed = await orch.spawn(SpawnSpec( managed = await orch.spawn(
id: _orchId, SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
role: 'fork ${widget.secondaryIndex}', );
sessionId: _sessionId!,
cwd: repoRoot,
forkSourceSessionId: forkSource,
));
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'Could not start fork: $e'); if (mounted) setState(() => _error = 'Could not start fork: $e');
return; return;
@@ -291,14 +290,16 @@ class _ClaudePaneState extends State<ClaudePane> {
final resume = await File(transcriptFile).exists(); final resume = await File(transcriptFile).exists();
try { try {
managed = await orch.spawn(SpawnSpec( managed = await orch.spawn(
SpawnSpec(
id: _orchId, id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!, sessionId: _sessionId!,
cwd: repoRoot, cwd: repoRoot,
resume: resume, resume: resume,
transcriptPath: resume ? transcriptFile : null, transcriptPath: resume ? transcriptFile : null,
)); ),
);
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = 'Could not start claude: $e'); if (mounted) setState(() => _error = 'Could not start claude: $e');
return; return;
@@ -426,13 +427,7 @@ class _ClaudePaneState extends State<ClaudePane> {
final dir = Directory(claudeProjectDir(root)); final dir = Directory(claudeProjectDir(root));
final sessions = await listSessions(dir); final sessions = await listSessions(dir);
if (!mounted) return; if (!mounted) return;
final picked = await dialog.show<String>( final picked = await dialog.show<String>((ctx, dismiss) => SessionPickerDialog(sessions: sessions, onPick: (id) => dismiss(id), onCancel: dismiss));
(ctx, dismiss) => SessionPickerDialog(
sessions: sessions,
onPick: (id) => dismiss(id),
onCancel: dismiss,
),
);
if (picked == null || !mounted) return; if (picked == null || !mounted) return;
setState(() => _statusLine = 'resuming…'); setState(() => _statusLine = 'resuming…');
await _respawnWithSession(picked); await _respawnWithSession(picked);
@@ -479,10 +474,7 @@ class _ClaudePaneState extends State<ClaudePane> {
final Widget body; final Widget body;
if (_error != null) { if (_error != null) {
body = Padding( body = Padding(padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true));
padding: const EdgeInsets.all(16),
child: ClideText(_error!, muted: true),
);
} else if (_conversation != null) { } else if (_conversation != null) {
// Rebuild conversation + composer zone together on each prompt change so // Rebuild conversation + composer zone together on each prompt change so
// the view hides a prompted tool-use card the moment its prompt appears // 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. // with the conversation; renders nothing when there are no tasks.
ListenableBuilder( ListenableBuilder(
listenable: _conversation!, 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 // An open prompt takes the composer's space and hides the text
// input until it's answered, so interaction stays out of the // 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)); body = const Center(child: ClideText('starting…', muted: true));
} }
final content = widget.showChrome final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
child: body,
)
: body;
// Surface this pane's status to the bottom status-bar slot while it's // Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150). // the focused pane (T-150).
return ClidePane( return ClidePane(contributionId: widget.contributionId, active: widget.active, statusWidget: _statusWidget(tokens), child: content);
contributionId: widget.contributionId,
active: widget.active,
statusWidget: _statusWidget(tokens),
child: content,
);
} }
} }
@@ -590,13 +571,7 @@ class _ModeBadge extends StatelessWidget {
return Semantics( return Semantics(
label: 'permission mode: ${permissionModeLabel(mode)}', label: 'permission mode: ${permissionModeLabel(mode)}',
excludeSemantics: true, excludeSemantics: true,
child: ClideText( child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
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. /// Public entry point used by the `claude.new-secondary` command.
void addSecondary() { void addSecondary() {
final index = _nextSecondary++; final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>( _controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index', id: 'secondary-$index',
title: 'session $index', title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index), payload: _Session(isPrimary: false, secondaryIndex: index),
)); ),
);
} }
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172). /// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
/// without touching the original. /// without touching the original.
void addFork(String sourceClaudeSessionId) { void addFork(String sourceClaudeSessionId) {
final index = _nextSecondary++; final index = _nextSecondary++;
_controller.add(MultitabEntry<_Session>( _controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index', id: 'secondary-$index',
title: 'fork $index', title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId), payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
)); ),
);
} }
@override @override
+5 -8
View File
@@ -8,12 +8,7 @@ library;
import 'dart:convert'; import 'dart:convert';
class DailyActivity { class DailyActivity {
const DailyActivity({ const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
required this.date,
required this.messageCount,
required this.sessionCount,
required this.toolCallCount,
});
final String date; // "YYYY-MM-DD" (sorts chronologically as a string) final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
final int messageCount; final int messageCount;
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
if (da is List) { if (da is List) {
for (final e in da) { for (final e in da) {
if (e is! Map) continue; if (e is! Map) continue;
daily.add(DailyActivity( daily.add(
DailyActivity(
date: '${e['date']}', date: '${e['date']}',
messageCount: _int(e['messageCount']), messageCount: _int(e['messageCount']),
sessionCount: _int(e['sessionCount']), sessionCount: _int(e['sessionCount']),
toolCallCount: _int(e['toolCallCount']), toolCallCount: _int(e['toolCallCount']),
)); ),
);
} }
} }
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily); 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.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
if (s.rateLimitInfo != null) s.rateLimitInfo!, if (s.rateLimitInfo != null) s.rateLimitInfo!,
].join(' · '); ].join(' · ');
return ( return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
leading: s.model != null ? shortModelLabel(s.model!) : null,
trailing: trailing.isEmpty ? null : trailing,
);
} }
/// Friendly label for Claude's permission modes. /// Friendly label for Claude's permission modes.
+9 -11
View File
@@ -62,10 +62,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
ClideDivider(), ClideDivider(),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6), padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
child: Column( child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [for (final t in tasks) _taskRow(tokens, t)]),
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), ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
if (!_expanded && current != null) ...[ if (!_expanded && current != null) ...[
const SizedBox(width: 10), 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 ] else
const Spacer(), const Spacer(),
], ],
@@ -106,14 +105,13 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(width: 8),
Expanded( Expanded(
child: ClideText( child: ClideText(t.text, fontSize: clideFontCaption, color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground),
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 /// written to [tempDir] (default [pasteCacheDir]) and attached by its
/// path. Returns an empty list when the clipboard holds neither, so the /// path. Returns an empty list when the clipboard holds neither, so the
/// composer pastes text instead. /// composer pastes text instead.
Future<List<ComposerAttachment>> resolveClipboardAttachment( Future<List<ComposerAttachment>> resolveClipboardAttachment(ClipboardSource source, {Directory? tempDir, DateTime Function() now = DateTime.now}) async {
ClipboardSource source, {
Directory? tempDir,
DateTime Function() now = DateTime.now,
}) async {
final files = await source.readFiles(); final files = await source.readFiles();
if (files.isNotEmpty) { if (files.isNotEmpty) {
return [ return [for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p))];
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
];
} }
final image = await source.readImage(); final image = await source.readImage();
+4 -20
View File
@@ -183,20 +183,13 @@ class _ConversationCardState extends State<ConversationCard> {
if (!_collapsed) ...[ if (!_collapsed) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
widget.body, widget.body,
for (final seg in widget.extraSegments) ...[ for (final seg in widget.extraSegments) ...[_segmentLabel(tokens, seg.label), seg.child],
_segmentLabel(tokens, seg.label),
seg.child,
],
], ],
], ],
); );
return Padding( return Padding(
padding: widget.margin, padding: widget.margin,
child: MouseRegion( child: MouseRegion(onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: _frame(tokens, content)),
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), onTap: () => setState(() => _collapsed = !_collapsed),
builder: (_, hovered, pressed) => Padding( builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6), padding: const EdgeInsets.only(right: 6),
child: ClideIcon( child: ClideIcon(_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'), size: 12, color: tokens.globalTextMuted),
_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, onTap: items[i].onTap,
builder: (_, hovered, pressed) => Padding( builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(left: 10), padding: const EdgeInsets.only(left: 10),
child: ClideText( child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
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] /// over stream-json so the pane would otherwise start empty. [onDispose]
/// is invoked from [dispose] — wire it to the reader's `dispose` so /// is invoked from [dispose] — wire it to the reader's `dispose` so
/// cancelling the view tears down the underlying tail. /// cancelling the view tears down the underlying tail.
ConversationController({ ConversationController({required Stream<ConversationItem> stream, Iterable<ConversationItem>? seed, Future<void> Function()? onDispose})
required Stream<ConversationItem> stream, : _onDispose = onDispose {
Iterable<ConversationItem>? seed,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose {
if (seed != null) _items.addAll(seed); if (seed != null) _items.addAll(seed);
_sub = stream.listen(_onItem); _sub = stream.listen(_onItem);
} }
@@ -34,13 +31,10 @@ class ConversationController extends ChangeNotifier {
/// the [ConversationItem]s a [TranscriptPublisher] writes onto /// the [ConversationItem]s a [TranscriptPublisher] writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several /// [publisher]/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140). /// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({ factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
required MessageBus messages, final stream = messages
String channel = ClaudeConversation.leadChannel, .subscribe(publisher: ClaudeConversation.publisher, channel: channel)
Future<void> Function()? onDispose, .map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
}) {
final stream =
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
return ConversationController(stream: stream, onDispose: onDispose); 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 — /// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
/// nested in a holder UNDER the Agent card (T-264), with a successful /// 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). /// 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(
Set<String> ownedSidechainUuids, List<ConversationItem> items,
Map<String, List<UserMessage>> promptsByToolUseId, ) {
Map<String, List<ConversationItem>> runByToolUseId,
}) _sidechainFold(List<ConversationItem> items) {
final agentByMsgUuid = <String, AssistantToolUse>{ final agentByMsgUuid = <String, AssistantToolUse>{
for (final it in items) for (final it in items)
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it, 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] /// 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). /// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
void _openFile(BuildContext context, String path, int? line) { 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. /// One conversation item, rendered by kind.
@@ -566,16 +564,13 @@ class _ConversationTurn extends StatelessWidget {
image: ClideFileImage(m.path), image: ClideFileImage(m.path),
fit: BoxFit.contain, fit: BoxFit.contain,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path), errorBuilder: (_, _, _) => _imagePlaceholder(m.path),
), ),
), ),
), ),
), ),
), ),
if (caption != null && caption.isNotEmpty) ...[ if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
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>( ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox( (ctx, dismiss) => ClideLightbox(
onDismiss: dismiss, onDismiss: dismiss,
child: Image( child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
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. // (note E): call input (body) → prompt → returned result.
final segments = <CardSegment>[ final segments = <CardSegment>[
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[]) for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)), CardSegment(
if (succeeded && !(isAgent && hasRun)) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))), 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 // A resolved permission-prompted call is tinted green if approved / red if
@@ -757,12 +755,7 @@ class _ConversationTurn extends StatelessWidget {
collapsible: quiet || multiline, collapsible: quiet || multiline,
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null, collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
body: ClideText( body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
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, collapsedSummary: multiline ? _firstLine(t.content) : null,
body: isOutputTool body: isOutputTool
? ClideCodeBlock(source: t.content, language: 'text') ? ClideCodeBlock(source: t.content, language: 'text')
: ClideText( : ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
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); _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 // 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 cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}'; final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
await orch.spawn(SpawnSpec( await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
id: forkId,
role: 'fork of $sourceId',
sessionId: forkId,
cwd: cwd,
forkSourceSessionId: source.sessionId,
));
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'}); 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. // its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot // flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160). // yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution( StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
]; ];
@override @override
@@ -399,13 +388,15 @@ class ClaudeExtension extends ClideExtension {
} }
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull; final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
if (target == null) return; if (target == null) return;
target.conversation.inject(ImageMessage( target.conversation.inject(
ImageMessage(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}', uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
timestamp: DateTime.now(), timestamp: DateTime.now(),
isSidechain: false, isSidechain: false,
path: path, path: path,
caption: m.data['caption'] as String?, caption: m.data['caption'] as String?,
)); ),
);
} }
@override @override
@@ -466,9 +457,7 @@ class ClaudeExtension extends ClideExtension {
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {}); if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}'); final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
final sessions = await listSessions(dir); final sessions = await listSessions(dir);
await ctx.dialog.show<Object>( await ctx.dialog.show<Object>((c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss));
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
);
return IpcResponse.ok(id: '', data: const {'status': 'shown'}); 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>( ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox( (ctx, dismiss) => ClideLightbox(
onDismiss: dismiss, onDismiss: dismiss,
child: Image( child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
),
), ),
); );
} }
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
child: Image( child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
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, side: ClideAnchorSide.above,
align: ClideAnchorAlign.end, align: ClideAnchorAlign.end,
offset: const Offset(0, -6), offset: const Offset(0, -6),
overlayBuilder: (ctx, ctrl) => ClideMenu( overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
onClose: ctrl.close,
minWidth: 180,
entries: _entries(ClideTheme.of(ctx).surface),
),
anchor: ListenableBuilder( anchor: ListenableBuilder(
listenable: _overlay, listenable: _overlay,
builder: (ctx, _) { builder: (ctx, _) {
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null, color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all( border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)), child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
+37 -43
View File
@@ -27,7 +27,8 @@ const _kOther = '\u0000other';
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny /// Preformatted note for the "Deny & simplify" permission option (T-311): deny
/// THIS action and ask Claude to reformulate it more simply, explicitly without /// THIS action and ask Claude to reformulate it more simply, explicitly without
/// touching the permission surface (memories / settings). /// 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) ' '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 ' '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; ' '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), if (_q.isNotEmpty) _questionBody(tokens, 0),
const SizedBox(height: 6), const SizedBox(height: 6),
Row(children: [ Row(
children: [
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null), ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
const Spacer(), const Spacer(),
_chatInstead(tokens), _chatInstead(tokens),
]), ],
),
], ],
); );
} }
@@ -312,11 +315,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 8), const SizedBox(height: 8),
for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i), for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i),
const SizedBox(height: 6), const SizedBox(height: 6),
Row(children: [ Row(
children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)), ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
const SizedBox(width: 8), const SizedBox(width: 8),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null), ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
]), ],
),
], ],
); );
} }
@@ -330,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 10), const SizedBox(height: 10),
_questionBody(tokens, _step), _questionBody(tokens, _step),
const SizedBox(height: 6), const SizedBox(height: 6),
Row(children: [ Row(
if (_step > 0) ...[ children: [
ClideButton(label: ' Back', onPressed: () => setState(() => _step--)), if (_step > 0) ...[ClideButton(label: ' Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
const SizedBox(width: 8), ClideButton(label: last ? 'Review ' : 'Next ', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
],
ClideButton(
label: last ? 'Review ' : 'Next ',
variant: ClideButtonVariant.primary,
onPressed: answered ? () => setState(() => _step++) : null,
),
const Spacer(), const Spacer(),
_chatInstead(tokens), _chatInstead(tokens),
]), ],
),
], ],
); );
} }
@@ -354,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
final done = _answer(i).isNotEmpty; final done = _answer(i).isNotEmpty;
final text = '${i + 1} · $head${done ? '' : ''}'; final text = '${i + 1} · $head${done ? '' : ''}';
if (i == _step) { if (i == _step) {
chips.add(Container( chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( 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), child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
)); ),
);
} else { } else {
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted)); 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( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox(width: 110, child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted)), SizedBox(
Expanded(child: ClideText('${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground)), 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), _optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
], ],
), ),
if (hasOther) ...[ if (hasOther) ...[const SizedBox(height: 8), _NoteField(controller: _other[qi], placeholder: 'type your answer…')],
const SizedBox(height: 8),
_NoteField(controller: _other[qi], placeholder: 'type your answer…'),
],
const SizedBox(height: 8), const SizedBox(height: 8),
_NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'), _NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'),
], ],
@@ -499,10 +506,7 @@ Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic>
/// / timeout annotations. /// / timeout annotations.
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) { Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
final cmd = (input['command'] as String? ?? '').trimRight(); final cmd = (input['command'] as String? ?? '').trimRight();
final notes = <String>[ final notes = <String>[if (input['run_in_background'] == true) 'background', if (input['timeout'] is num) 'timeout ${input['timeout']}ms'];
if (input['run_in_background'] == true) 'background',
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -564,12 +568,7 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
if (pat != null && pat.isNotEmpty) extra.add('"$pat"'); if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
} }
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' '); final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
return ClideText( return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
label.isNotEmpty ? label : toolName,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
color: tokens.globalForeground,
);
} }
/// A muted file path line, shared across tool bodies. /// A muted file path line, shared across tool bodies.
@@ -613,7 +612,7 @@ class _NoteFieldState extends State<_NoteField> {
children: [ children: [
ValueListenableBuilder<TextEditingValue>( ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller, 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( EditableText(
controller: widget.controller, controller: widget.controller,
@@ -652,14 +651,9 @@ List<_Question> _parseQuestions(Map<String, dynamic> input) {
return [ return [
for (final q in raw) for (final q in raw)
if (q is Map) if (q is Map)
_Question( _Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
q['question'] as String? ?? '',
q['header'] as String? ?? '',
q['multiSelect'] as bool? ?? false,
[
for (final o in (q['options'] as List? ?? const [])) for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''), 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. /// One session in the workspace, summarised for the picker.
class SessionSummary { class SessionSummary {
const SessionSummary({ const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
required this.id,
required this.modified,
this.firstUser,
this.lastUser,
this.sizeBytes = 0,
});
/// The session id (the `<uuid>` of `<uuid>.jsonl`). /// The session id (the `<uuid>` of `<uuid>.jsonl`).
final String id; final String id;
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
/// Sessions in [dir] (the munged project dir), most-recently-modified first, /// Sessions in [dir] (the munged project dir), most-recently-modified first,
/// capped at [max]. Each is summarised by bookend user prompts read from a /// capped at [max]. Each is summarised by bookend user prompts read from a
/// bounded [window] at each end of its transcript. /// bounded [window] at each end of its transcript.
Future<List<SessionSummary>> listSessions( Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
Directory dir, {
int max = 20,
int window = 128 * 1024,
}) async {
if (!await dir.exists()) return const []; if (!await dir.exists()) return const [];
final files = <File>[]; final files = <File>[];
await for (final e in dir.list(followLinks: false)) { await for (final e in dir.list(followLinks: false)) {
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
final stat = await f.stat(); final stat = await f.stat();
final bookends = await _bookends(f, window); final bookends = await _bookends(f, window);
final id = _sessionId(f.path); final id = _sessionId(f.path);
summaries.add(SessionSummary( summaries.add(
SessionSummary(
id: id, id: id,
modified: stat.modified, modified: stat.modified,
firstUser: bookends.first, firstUser: bookends.first,
lastUser: bookends.last, lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')), sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
)); ),
);
} }
summaries.sort((a, b) => b.modified.compareTo(a.modified)); summaries.sort((a, b) => b.modified.compareTo(a.modified));
return summaries.length > max ? summaries.sublist(0, max) : summaries; 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 /// Creates the subprocess for a session — production uses
/// [ClaudeStreamJsonProcess.start]; tests inject a fake. /// [ClaudeStreamJsonProcess.start]; tests inject a fake.
typedef ProcessFactory = Future<StreamJsonProcess> Function({ typedef ProcessFactory = Future<StreamJsonProcess> Function({required List<String> sessionArgs, required String cwd, Map<String, String>? env});
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
});
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`, /// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
/// `teammate:tyre`); [sessionId] is claude's `--session-id`. /// `teammate:tyre`); [sessionId] is claude's `--session-id`.
@@ -150,10 +146,7 @@ ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier { class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude { ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
_chatModel = TeamChatModel( _chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
broker: broker,
sessionResolver: (name) => byMemberName(name)?.session,
);
} }
final ProcessFactory _factory; final ProcessFactory _factory;
@@ -224,18 +217,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
preambles.add(_teamSystemPrompt(name, spec.role)); preambles.add(_teamSystemPrompt(name, spec.role));
} }
final bootstrap = agentBootstrap(spec.cwd, base: spec.env); final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
sessionArgs = [ sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
'--append-system-prompt',
preambles.join('\n\n'),
...bootstrap.extraArgs,
...sessionArgs,
];
final proc = await _factory( final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
sessionArgs: sessionArgs,
cwd: spec.cwd,
env: bootstrap.envDelta,
);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start(); final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null; final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose); 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). /// 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: ' 'Coordinate with teammates using the clide-team MCP tools: '
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, ' '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 ' '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'; import 'package:flutter/widgets.dart';
class SessionPickerDialog extends StatefulWidget { class SessionPickerDialog extends StatefulWidget {
const SessionPickerDialog({ const SessionPickerDialog({super.key, required this.sessions, required this.onPick, required this.onCancel});
super.key,
required this.sessions,
required this.onPick,
required this.onCancel,
});
final List<SessionSummary> sessions; final List<SessionSummary> sessions;
final void Function(String id) onPick; final void Function(String id) onPick;
@@ -92,11 +87,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
) )
else else
Flexible( Flexible(
child: ListView.builder( child: ListView.builder(shrinkWrap: true, itemCount: widget.sessions.length, itemBuilder: (ctx, i) => _row(theme, i)),
shrinkWrap: true,
itemCount: widget.sessions.length,
itemBuilder: (ctx, i) => _row(theme, i),
),
), ),
], ],
), ),
@@ -117,13 +108,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText( ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 2, overflow: TextOverflow.ellipsis),
s.label,
fontSize: clideFontSmall,
color: theme.globalForeground,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2), const SizedBox(height: 2),
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall), 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); typedef SessionDeleter = Future<void> Function(Directory dir, String id);
class SessionStorageDialog extends StatefulWidget { class SessionStorageDialog extends StatefulWidget {
const SessionStorageDialog({ const SessionStorageDialog({super.key, required this.dir, required this.sessions, required this.onClose, this.deleter = deleteSession});
super.key,
required this.dir,
required this.sessions,
required this.onClose,
this.deleter = deleteSession,
});
final Directory dir; final Directory dir;
final List<SessionSummary> sessions; final List<SessionSummary> sessions;
@@ -79,19 +73,11 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4), padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: ClideText( child: ClideText('Session storage · ${formatBytes(_total)} total', fontSize: clideFontBody, color: theme.globalForeground),
'Session storage · ${formatBytes(_total)} total',
fontSize: clideFontBody,
color: theme.globalForeground,
),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ClideText( child: ClideText('Deleting a session you are currently using will break that pane.', muted: true, fontSize: clideFontSmall),
'Deleting a session you are currently using will break that pane.',
muted: true,
fontSize: clideFontSmall,
),
), ),
if (_sessions.isEmpty) if (_sessions.isEmpty)
Padding( Padding(
@@ -100,11 +86,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
) )
else else
Flexible( Flexible(
child: ListView.builder( child: ListView.builder(shrinkWrap: true, itemCount: _sessions.length, itemBuilder: (ctx, i) => _row(theme, _sessions[i])),
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]` /// 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). /// for a new session or `['--resume', id]` to resume an existing one (T-161).
static Future<ClaudeStreamJsonProcess> start({ static Future<ClaudeStreamJsonProcess> start({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) async {
required List<String> sessionArgs,
required String cwd,
Map<String, String>? env,
}) async {
final proc = await Process.start( final proc = await Process.start(
'claude', 'claude',
[ [
@@ -310,7 +306,8 @@ class StreamJsonSession {
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent // 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. // when we actually host a server, so a plain session is unchanged.
if (_mcpServers.isNotEmpty) { if (_mcpServers.isNotEmpty) {
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_request', 'type': 'control_request',
'request_id': 'init-${_localSeq++}', 'request_id': 'init-${_localSeq++}',
'request': { 'request': {
@@ -318,7 +315,8 @@ class StreamJsonSession {
'hooks': <String, dynamic>{}, 'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name], '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 input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final tuid = request['tool_use_id'] as String? ?? ''; final tuid = request['tool_use_id'] as String? ?? '';
if (tuid.isNotEmpty) _promptedToolUses.add(tuid); if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
_queue.add(ToolPrompt( _queue.add(
ToolPrompt(
promptId: rid, promptId: rid,
toolName: toolName, toolName: toolName,
displayName: request['display_name'] as String? ?? toolName, displayName: request['display_name'] as String? ?? toolName,
@@ -456,7 +455,8 @@ class StreamJsonSession {
toolUseId: request['tool_use_id'] as String? ?? '', toolUseId: request['tool_use_id'] as String? ?? '',
input: input, input: input,
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [], permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
)); ),
);
_pendingCtl.add(pendingPrompt); _pendingCtl.add(pendingPrompt);
return; // awaits resolvePrompt return; // awaits resolvePrompt
} }
@@ -465,10 +465,12 @@ class StreamJsonSession {
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>())); unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
return; return;
} }
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_response', 'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'}, '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 /// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
@@ -489,14 +491,16 @@ class StreamJsonSession {
} else { } else {
mcpResponse = await _dispatchMcp(server, message); mcpResponse = await _dispatchMcp(server, message);
} }
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_response', 'type': 'control_response',
'response': { 'response': {
'subtype': 'success', 'subtype': 'success',
'request_id': rid, 'request_id': rid,
'response': {'mcp_response': mcpResponse} 'response': {'mcp_response': mcpResponse},
}, },
})); }),
);
} }
McpServer? _mcpServerNamed(String? name) { McpServer? _mcpServerNamed(String? name) {
@@ -559,10 +563,12 @@ class StreamJsonSession {
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool; _toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId); if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
} }
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_response', 'type': 'control_response',
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()}, 'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
})); }),
);
if (decision is AllowTool) { if (decision is AllowTool) {
// The prompt card is ephemeral (it vanishes once resolved), so leave a // The prompt card is ephemeral (it vanishes once resolved), so leave a
// compact record of an answered question in the conversation log (D-78). // 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 /// so it renders immediately (stream-json doesn't replay stdin without
/// `--replay-user-messages`). /// `--replay-user-messages`).
void send(String text) { void send(String text) {
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'user', 'type': 'user',
'message': {'role': 'user', 'content': text}, 'message': {'role': 'user', 'content': text},
})); }),
_items.add(UserMessage( );
uuid: 'local-${_localSeq++}', _items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
timestamp: DateTime.now(),
isSidechain: false,
text: text,
));
_setBusy(true); _setBusy(true);
} }
@@ -669,11 +672,13 @@ class StreamJsonSession {
/// the `interrupt` control_request; claude cancels the current turn and ends /// the `interrupt` control_request; claude cancels the current turn and ends
/// it with a `result`, which clears [busy]. Safe to call when idle. /// it with a `result`, which clears [busy]. Safe to call when idle.
void interrupt() { void interrupt() {
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_request', 'type': 'control_request',
'request_id': 'interrupt-${_localSeq++}', 'request_id': 'interrupt-${_localSeq++}',
'request': {'subtype': 'interrupt'}, 'request': {'subtype': 'interrupt'},
})); }),
);
} }
/// Set the session's permission mode (T-181, D-77). Sends a /// 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 /// cockpit badge's plain click; bypassPermissions is reachable only via a
/// confirmed shift-click (T-181). /// confirmed shift-click (T-181).
void setPermissionMode(String mode) { void setPermissionMode(String mode) {
_proc.writeLine(jsonEncode({ _proc.writeLine(
jsonEncode({
'type': 'control_request', 'type': 'control_request',
'request_id': 'set-perm-${_localSeq++}', 'request_id': 'set-perm-${_localSeq++}',
'request': {'subtype': 'set_permission_mode', 'mode': mode}, 'request': {'subtype': 'set_permission_mode', 'mode': mode},
})); }),
);
// Optimistically reflect the change so the badge / status line update // Optimistically reflect the change so the badge / status line update
// immediately (T-250) — the control_request emits no status event, and a // immediately (T-250) — the control_request emits no status event, and a
// fresh system/init only arrives later. The next init reconciles if the // fresh system/init only arrives later. The next init reconciles if the
+6 -22
View File
@@ -35,13 +35,7 @@ class TeamMemberRef {
/// A message left for a member, in arrival order. /// A message left for a member, in arrival order.
class TeamMessage { class TeamMessage {
const TeamMessage({ const TeamMessage({required this.from, required this.text, required this.at, this.to, this.broadcast = false});
required this.from,
required this.text,
required this.at,
this.to,
this.broadcast = false,
});
final String from; final String from;
/// Recipient name: a single member's display name (direct message), `null` /// Recipient name: a single member's display name (direct message), `null`
@@ -52,13 +46,7 @@ class TeamMessage {
final DateTime at; final DateTime at;
final bool broadcast; final bool broadcast;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {'from': from, if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true};
'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`. /// A shared task. Status is one of `open` / `claimed` / `done`.
@@ -69,12 +57,7 @@ class TeamTask {
String status; String status;
String? owner; String? owner;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {'id': id, 'title': title, 'status': status, if (owner != null) 'owner': owner};
'id': id,
'title': title,
'status': status,
if (owner != null) 'owner': owner,
};
} }
/// Pushes [text] into the member identified by [toMemberId] as a user message /// 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 {'ok': true, 'task': t.toJson()};
} }
return { 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?)); return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
case 'task_status': case 'task_status':
return _result( 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: default:
return _error('Unknown team tool: $name'); 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, /// Lifetime matches the orchestrator: created once, subscribed to the broker,
/// disposed when the orchestrator is torn down. /// disposed when the orchestrator is torn down.
class TeamChatModel { class TeamChatModel {
TeamChatModel({ TeamChatModel({required TeamBroker broker, SessionResolver? sessionResolver}) : _broker = broker, _sessionResolver = sessionResolver {
required TeamBroker broker,
SessionResolver? sessionResolver,
}) : _broker = broker,
_sessionResolver = sessionResolver {
_sub = broker.messages.listen(_onMessage); _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 /// [onPopOut] is called when the user taps the pop-out icon to open the full
/// pane — the extension wires this to `panels.activateTab`. /// pane — the extension wires this to `panels.activateTab`.
class TeamChatSidebar extends StatefulWidget { class TeamChatSidebar extends StatefulWidget {
const TeamChatSidebar({ const TeamChatSidebar({super.key, required this.model, required this.broker, required this.onPopOut});
super.key,
required this.model,
required this.broker,
required this.onPopOut,
});
final TeamChatModel model; final TeamChatModel model;
final TeamBroker broker; final TeamBroker broker;
@@ -149,11 +144,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
onTap: widget.onPopOut, onTap: widget.onPopOut,
builder: (ctx, hovered, _) => Padding( builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: ClideIcon( child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
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', formatLabel: (n) => '@$n',
child: Focus( child: Focus(
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: _ChatInputField( child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
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 /// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the
/// interrupt tickbox and full @-completion. /// interrupt tickbox and full @-completion.
class TeamChatPane extends StatefulWidget { class TeamChatPane extends StatefulWidget {
const TeamChatPane({ const TeamChatPane({super.key, required this.model, required this.broker});
super.key,
required this.model,
required this.broker,
});
final TeamChatModel model; final TeamChatModel model;
final TeamBroker broker; final TeamBroker broker;
@@ -231,11 +212,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
// Scroll to bottom on new message. // Scroll to bottom on new message.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.animateTo( _scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut);
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
);
} }
}); });
} }
@@ -297,11 +274,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
final text = raw.trim(); final text = raw.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
final parsed = parseAtTag(text); final parsed = parseAtTag(text);
widget.model.postAsUser( widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient, interrupt: _interrupt);
parsed.body.isEmpty ? text : parsed.body,
toName: parsed.recipient,
interrupt: _interrupt,
);
_controller.clear(); _controller.clear();
} }
@@ -340,11 +313,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (_, i) => _ChatRow( itemBuilder: (_, i) => _ChatRow(key: ValueKey(messages[i].at.microsecondsSinceEpoch), message: messages[i], tokens: tokens),
key: ValueKey(messages[i].at.microsecondsSinceEpoch),
message: messages[i],
tokens: tokens,
),
), ),
), ),
// Composer + interrupt tickbox. // Composer + interrupt tickbox.
@@ -375,24 +344,13 @@ class _TeamChatPaneState extends State<TeamChatPane> {
margin: const EdgeInsets.only(right: 5), margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000), color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000),
border: Border.all( border: Border.all(color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, width: 1),
color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted,
width: 1,
),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
child: _interrupt child: _interrupt ? Center(child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus)) : null,
? Center(
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
)
: null,
), ),
), ),
ClideText( ClideText('Interrupt', fontSize: clideFontSmall, color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted),
'Interrupt',
fontSize: clideFontSmall,
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
),
], ],
), ),
), ),
@@ -405,13 +363,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
formatLabel: (n) => '@$n', formatLabel: (n) => '@$n',
child: Focus( child: Focus(
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: _ChatInputField( child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
controller: _controller,
focusNode: _focusNode,
tokens: tokens,
onSubmit: _submit,
placeholder: '@name or @team …',
),
), ),
), ),
], ],
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
margin: const EdgeInsets.only(right: 5, top: 1), margin: const EdgeInsets.only(right: 5, top: 1),
decoration: BoxDecoration( decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
color: senderColor.withAlpha(30),
borderRadius: BorderRadius.circular(2),
),
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor), child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
), ),
if (toLabel != null) if (toLabel != null)
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
/// Inline text input for the chat composer. /// Inline text input for the chat composer.
class _ChatInputField extends StatelessWidget { class _ChatInputField extends StatelessWidget {
const _ChatInputField({ const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
required this.controller,
required this.focusNode,
required this.tokens,
required this.onSubmit,
required this.placeholder,
});
final TextEditingController controller; final TextEditingController controller;
final FocusNode focusNode; final FocusNode focusNode;
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
child: EditableText( child: EditableText(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
style: TextStyle( style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
cursorColor: tokens.globalFocus, cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted, backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit, onSubmitted: onSubmit,
+3 -14
View File
@@ -51,10 +51,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
void _onJoined(TeamMemberJoined m) { void _onJoined(TeamMemberJoined m) {
if (_controllers.containsKey(m.agentId)) return; if (_controllers.containsKey(m.agentId)) return;
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
_controllers[m.agentId] = ConversationController.fromBus( _controllers[m.agentId] = ConversationController.fromBus(messages: kernel.messages, channel: ClaudeConversation.teammateChannel(m.agentId));
messages: kernel.messages,
channel: ClaudeConversation.teammateChannel(m.agentId),
);
setState(() => _members.add(m)); setState(() => _members.add(m));
} }
@@ -92,11 +89,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
}), }),
), ),
Expanded( Expanded(
child: _TeammateGrid( child: _TeammateGrid(members: _members, controllers: _controllers, tokens: tokens),
members: _members,
controllers: _controllers,
tokens: tokens,
),
), ),
], ],
); );
@@ -145,11 +138,7 @@ class _TeammateGrid extends StatelessWidget {
children: [ children: [
for (var r = 0; r < rows; r++) for (var r = 0; r < rows; r++)
Expanded( Expanded(
child: Row( child: Row(children: [for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c))]),
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 id = data['id'] as String?;
final status = data['status'] as String?; final status = data['status'] as String?;
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) { 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], 'ids': [id],
'status': 'in_progress', 'status': 'in_progress',
}); },
);
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id}); if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
} }
return true; return true;
@@ -50,16 +50,11 @@ class TranscriptPublisher {
/// [ClaudeConversation.publisher] / [channel]. The subscription is /// [ClaudeConversation.publisher] / [channel]. The subscription is
/// attached synchronously, so a controller that subscribes before the /// attached synchronously, so a controller that subscribes before the
/// reader's first poll never misses the initial tail. /// reader's first poll never misses the initial tail.
TranscriptPublisher({ TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
required MessageBus messages, : _messages = messages,
required TranscriptReader reader,
this.channel = ClaudeConversation.leadChannel,
}) : _messages = messages,
_reader = reader { _reader = reader {
_sub = _reader.stream.listen((item) { _sub = _reader.stream.listen((item) {
_messages.publish(ClaudeConversation.publisher, channel, { _messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
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 /// driver has already resolved (workspace-relative paths are resolved before
/// injection); [caption] is an optional one-line label. /// injection); [caption] is an optional one-line label.
final class ImageMessage extends ConversationItem { final class ImageMessage extends ConversationItem {
const ImageMessage({ const ImageMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.path, this.caption});
required super.uuid,
required super.timestamp,
required super.isSidechain,
required this.path,
this.caption,
});
/// Absolute path to the image file on disk. /// Absolute path to the image file on disk.
final String path; final String path;
@@ -215,14 +209,7 @@ const _isolateParseThreshold = 64 * 1024;
const _knownMajorVersions = {1, 2}; const _knownMajorVersions = {1, 2};
/// Record types to skip (do not emit as conversation items). /// Record types to skip (do not emit as conversation items).
const _skipTypes = { const _skipTypes = {'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'};
'attachment',
'system',
'last-prompt',
'permission-mode',
'file-history-snapshot',
'queue-operation',
};
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s. /// 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, /// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
/// and the reader [merge]s deltas into a running status. /// and the reader [merge]s deltas into a running status.
class SessionStatus { class SessionStatus {
const SessionStatus({ const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
this.model,
this.permissionMode,
this.contextTokens,
this.cost,
this.contextWindow,
this.rateLimitInfo,
});
/// Assistant `message.model`, e.g. `claude-opus-4-7`. /// Assistant `message.model`, e.g. `claude-opus-4-7`.
final String? model; 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 majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
final major = int.tryParse(majorStr); final major = int.tryParse(majorStr);
if (major != null && !_knownMajorVersions.contains(major)) { if (major != null && !_knownMajorVersions.contains(major)) {
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); ' warnings.add(
'parsing will degrade gracefully'); 'unfamiliar transcript version "$rawVersion" (major=$major); '
'parsing will degrade gracefully',
);
} }
} }
@@ -628,14 +610,17 @@ void _parseUserInto(
if (content is String) { if (content is String) {
if (content.isNotEmpty) { if (content.isNotEmpty) {
out.add(UserMessage( out.add(
UserMessage(
uuid: uuid, uuid: uuid,
timestamp: timestamp, timestamp: timestamp,
isSidechain: isSidechain, isSidechain: isSidechain,
parentUuid: parentUuid, parentUuid: parentUuid,
parentToolUseId: parentToolUseId, parentToolUseId: parentToolUseId,
text: content, text: content,
injected: injected)); injected: injected,
),
);
} }
return; return;
} }
@@ -650,7 +635,8 @@ void _parseUserInto(
if (text.isNotEmpty) textParts.add(text); if (text.isNotEmpty) textParts.add(text);
case 'tool_result': case 'tool_result':
final rawContent = item['content']; final rawContent = item['content'];
out.add(ToolResultMessage( out.add(
ToolResultMessage(
uuid: uuid, uuid: uuid,
timestamp: timestamp, timestamp: timestamp,
isSidechain: isSidechain, isSidechain: isSidechain,
@@ -659,7 +645,8 @@ void _parseUserInto(
toolUseId: item['tool_use_id'] as String? ?? '', toolUseId: item['tool_use_id'] as String? ?? '',
content: rawContent is String ? rawContent : jsonEncode(rawContent), content: rawContent is String ? rawContent : jsonEncode(rawContent),
isError: item['is_error'] as bool? ?? false, isError: item['is_error'] as bool? ?? false,
)); ),
);
default: default:
break; break;
} }
@@ -689,18 +676,35 @@ void _parseAssistantInto(
case 'text': case 'text':
final text = item['text'] as String? ?? ''; final text = item['text'] as String? ?? '';
if (text.isNotEmpty) { if (text.isNotEmpty) {
out.add(AssistantTextMessage( out.add(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, text: text)); AssistantTextMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
text: text,
),
);
} }
case 'thinking': case 'thinking':
final thinking = item['thinking'] as String? ?? ''; final thinking = item['thinking'] as String? ?? '';
if (thinking.isNotEmpty) { if (thinking.isNotEmpty) {
out.add(AssistantThinkingMessage( out.add(
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, thinking: thinking)); AssistantThinkingMessage(
uuid: uuid,
timestamp: timestamp,
isSidechain: isSidechain,
parentUuid: parentUuid,
parentToolUseId: parentToolUseId,
thinking: thinking,
),
);
} }
case 'tool_use': case 'tool_use':
final rawInput = item['input']; final rawInput = item['input'];
out.add(AssistantToolUse( out.add(
AssistantToolUse(
uuid: uuid, uuid: uuid,
timestamp: timestamp, timestamp: timestamp,
isSidechain: isSidechain, isSidechain: isSidechain,
@@ -709,7 +713,8 @@ void _parseAssistantInto(
toolUseId: item['id'] as String? ?? '', toolUseId: item['id'] as String? ?? '',
name: item['name'] as String? ?? '', name: item['name'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{}, input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
)); ),
);
default: default:
break; break;
} }
+2 -9
View File
@@ -70,19 +70,12 @@ class CliInstallExtension extends ClideExtension {
final ctx = _ctx; final ctx = _ctx;
if (r.ok) { if (r.ok) {
ctx?.notify.success(r.message, title: 'clide CLI installed'); ctx?.notify.success(r.message, title: 'clide CLI installed');
return IpcResponse.ok(id: '', data: { return IpcResponse.ok(id: '', data: {'installed': r.installedPath, 'onPath': r.onPath});
'installed': r.installedPath,
'onPath': r.onPath,
});
} }
ctx?.notify.error(r.message, title: 'clide CLI install failed'); ctx?.notify.error(r.message, title: 'clide CLI install failed');
return IpcResponse.err( return IpcResponse.err(
id: '', id: '',
error: IpcError( error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: r.message,
),
); );
}, },
), ),
+3 -15
View File
@@ -1,11 +1,7 @@
import 'dart:ui' show Color; import 'dart:ui' show Color;
class DecisionTypeColors { class DecisionTypeColors {
const DecisionTypeColors({ const DecisionTypeColors({required this.confirmed, required this.question, required this.rejected});
required this.confirmed,
required this.question,
required this.rejected,
});
final Color confirmed; final Color confirmed;
final Color question; final Color question;
@@ -18,17 +14,9 @@ class DecisionTypeColors {
_ => confirmed, _ => confirmed,
}; };
static const dark = DecisionTypeColors( static const dark = DecisionTypeColors(confirmed: Color(0xFF7DD3A8), question: Color(0xFFE6C370), rejected: Color(0xFFE87D7D));
confirmed: Color(0xFF7DD3A8),
question: Color(0xFFE6C370),
rejected: Color(0xFFE87D7D),
);
static const light = DecisionTypeColors( static const light = DecisionTypeColors(confirmed: Color(0xFF1D7A4E), question: Color(0xFFB08A20), rejected: Color(0xFFC03030));
confirmed: Color(0xFF1D7A4E),
question: Color(0xFFB08A20),
rejected: Color(0xFFC03030),
);
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light; static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
} }
@@ -110,10 +110,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
return ClidePaneChrome( return ClidePaneChrome(
title: id, title: id,
subtitle: title, subtitle: title,
leading: ReaderPinButton( leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _decision != null ? _onPin : null),
pinned: _nav?.hasPinned ?? false,
onTap: _decision != null ? _onPin : null,
),
trailing: [ trailing: [
ReaderActionBar( ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false, canGoBack: _nav?.canGoBack ?? false,
@@ -144,7 +141,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
children: [ children: [
ClideTooltip( ClideTooltip(
message: type ?? 'confirmed', 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), const SizedBox(width: 8),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily), ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
@@ -159,21 +160,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500), ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[ if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
const SizedBox(height: 6), if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
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) ...[ if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
const SizedBox(height: 12),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
],
if (refs.isNotEmpty) ...[ if (refs.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), 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 hasFilter = lf.isNotEmpty;
final filtered = hasFilter final filtered = hasFilter
? _decisions ? _decisions
.where((d) => .where(
(d) =>
d.id.toLowerCase().contains(lf) || d.id.toLowerCase().contains(lf) ||
d.title.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) ||
(d.domain ?? '').toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf) ||
(d.type ?? '').contains(lf)) (d.type ?? '').contains(lf),
)
.toList() .toList()
: _decisions; : _decisions;
@@ -158,7 +160,9 @@ class _DecisionsViewState extends State<DecisionsView> {
children: [ children: [
Row( Row(
children: [ 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(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ClideTappable( child: ClideTappable(
@@ -180,39 +184,66 @@ class _DecisionsViewState extends State<DecisionsView> {
ClideAccordion( ClideAccordion(
label: 'CONFIRMED', label: 'CONFIRMED',
count: confirmed.length, 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'), expanded: hasFilter || _isSectionExpanded('confirmed'),
onToggle: () => _toggleSection('confirmed'), onToggle: () => _toggleSection('confirmed'),
children: [ children: [
for (final d in confirmed) for (final d in confirmed)
_DecisionCard( _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) if (questions.isNotEmpty)
ClideAccordion( ClideAccordion(
label: 'QUESTIONS', label: 'QUESTIONS',
count: questions.length, 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'), expanded: hasFilter || _isSectionExpanded('question'),
onToggle: () => _toggleSection('question'), onToggle: () => _toggleSection('question'),
children: [ children: [
for (final d in questions) for (final d in questions)
_DecisionCard( _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) if (rejected.isNotEmpty)
ClideAccordion( ClideAccordion(
label: 'REJECTED', label: 'REJECTED',
count: rejected.length, 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'), expanded: hasFilter || _isSectionExpanded('rejected'),
onToggle: () => _toggleSection('rejected'), onToggle: () => _toggleSection('rejected'),
children: [ children: [
for (final d in rejected) for (final d in rejected)
_DecisionCard( _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: [ children: [
ClideTooltip( ClideTooltip(
message: entry.type ?? 'confirmed', 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), const SizedBox(width: 6),
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
+1 -6
View File
@@ -25,12 +25,7 @@ class DeepLinkExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
CommandContribution( CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke),
id: 'deeplink.invoke',
command: 'deeplink.invoke',
title: 'Open a clide:// deep link',
run: _invoke,
),
]; ];
@override @override
+9 -60
View File
@@ -16,19 +16,8 @@ class DefaultLayoutExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
_preset ?? classicPreset(), _preset ?? classicPreset(),
CommandContribution( CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset),
id: 'layout.reset', CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette),
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) // Collapse toggles (D-051, D-054)
CommandContribution( CommandContribution(
id: 'sidebar.collapse', id: 'sidebar.collapse',
@@ -45,35 +34,11 @@ class DefaultLayoutExtension extends ClideExtension {
run: _collapseContext, run: _collapseContext,
), ),
// Panel focus (D-054) // Panel focus (D-054)
CommandContribution( CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
id: 'panel.focus.left', CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
command: 'panel.focus.left', CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
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) // Focus mode (D-052, D-054)
CommandContribution( CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution( CommandContribution(
id: 'panel.focusMode.exit', id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit', command: 'panel.focusMode.exit',
@@ -86,20 +51,8 @@ class DefaultLayoutExtension extends ClideExtension {
run: _exitFocusMode, run: _exitFocusMode,
), ),
// Editor split (D-049, D-054) // Editor split (D-049, D-054)
CommandContribution( CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
id: 'editor.open', CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
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 // Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
CommandContribution( CommandContribution(
@@ -314,10 +267,6 @@ class DefaultLayoutExtension extends ClideExtension {
static IpcResponse _notActivated() => IpcResponse.err( static IpcResponse _notActivated() => IpcResponse.err(
id: '', id: '',
error: IpcError( error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'not activated'),
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]. /// Load diffs. Optionally filter to [paths] and toggle [staged].
Future<void> load({ Future<void> load({bool staged = false, List<String> paths = const []}) async {
bool staged = false,
List<String> paths = const [],
}) async {
_staged = staged; _staged = staged;
_loading = true; _loading = true;
notifyListeners(); notifyListeners();
final r = await ipc.request('git.diff', args: { final r = await ipc.request('git.diff', args: {'staged': staged, if (paths.isNotEmpty) 'paths': paths});
'staged': staged,
if (paths.isNotEmpty) 'paths': paths,
});
_loading = false; _loading = false;
if (!r.ok) { if (!r.ok) {
+15 -78
View File
@@ -98,24 +98,11 @@ class _DiffViewState extends State<DiffView> {
if (c.error != null) if (c.error != null)
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ClideText( child: ClideText(c.error!, color: tokens.statusError),
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) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.diffs.isEmpty && c.error == null) if (!c.loading && c.diffs.isEmpty && c.error == null)
Padding( Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
padding: const EdgeInsets.all(12),
child: ClideText(
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
muted: true,
),
),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _scroll, controller: _scroll,
@@ -163,11 +150,7 @@ class _DiffToolbar extends StatelessWidget {
label: 'show unstaged changes', label: 'show unstaged changes',
child: GestureDetector( child: GestureDetector(
onTap: controller.showStaged ? controller.toggleStaged : null, onTap: controller.showStaged ? controller.toggleStaged : null,
child: ClideText( child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
'Unstaged',
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -177,11 +160,7 @@ class _DiffToolbar extends StatelessWidget {
label: 'show staged changes', label: 'show staged changes',
child: GestureDetector( child: GestureDetector(
onTap: controller.showStaged ? null : controller.toggleStaged, onTap: controller.showStaged ? null : controller.toggleStaged,
child: ClideText( child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
'Staged',
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
),
), ),
), ),
], ],
@@ -233,11 +212,7 @@ class _FileDiff extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ClideText( child: ClideText(path, fontSize: clideFontCaption, color: focused ? tokens.globalFocus : tokens.panelHeaderForeground),
path,
fontSize: clideFontCaption,
color: focused ? tokens.globalFocus : tokens.panelHeaderForeground,
),
), ),
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess), if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError), 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), child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
), ),
if (!isBinary) if (!isBinary)
for (final hunk in hunks) for (final hunk in hunks) _HunkView(hunk: (hunk as Map).cast<String, Object?>(), filePath: path, controller: controller),
_HunkView(
hunk: (hunk as Map).cast<String, Object?>(),
filePath: path,
controller: controller,
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
); );
@@ -263,11 +233,7 @@ class _FileDiff extends StatelessWidget {
} }
class _HunkView extends StatelessWidget { class _HunkView extends StatelessWidget {
const _HunkView({ const _HunkView({required this.hunk, required this.filePath, required this.controller});
required this.hunk,
required this.filePath,
required this.controller,
});
final Map<String, Object?> hunk; final Map<String, Object?> hunk;
final String filePath; final String filePath;
@@ -284,17 +250,9 @@ class _HunkView extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: ClideText( child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
header,
fontSize: clideFontMono,
muted: true,
fontFamily: clideMonoFamily,
),
),
for (final lineObj in lines)
_DiffLineRow(
line: (lineObj as Map).cast<String, Object?>(),
), ),
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
], ],
); );
} }
@@ -313,18 +271,9 @@ class _DiffLineRow extends StatelessWidget {
final newLineNo = line['newLineNo'] as num?; final newLineNo = line['newLineNo'] as num?;
final (Color bg, Color fg) = switch (kind) { final (Color bg, Color fg) = switch (kind) {
'addition' => ( 'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
tokens.statusSuccess.withValues(alpha: 0.15), 'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
tokens.statusSuccess, _ => (const Color(0x00000000), tokens.globalForeground),
),
'removal' => (
tokens.statusError.withValues(alpha: 0.15),
tokens.statusError,
),
_ => (
const Color(0x00000000),
tokens.globalForeground,
),
}; };
final prefix = switch (kind) { final prefix = switch (kind) {
@@ -361,22 +310,10 @@ class _DiffLineRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
ClideText( ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
prefix,
fontSize: clideFontMono,
color: fg,
fontFamily: clideMonoFamily,
),
const SizedBox(width: 2), const SizedBox(width: 2),
Expanded( Expanded(
child: ClideText( child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
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; if (raw is! List) return;
_buffers = [ _buffers = [
for (final b in raw) for (final b in raw)
if (b is Map) if (b is Map) (id: b['id']! as String, path: b['path']! as String, dirty: (b['dirty'] as bool?) ?? false),
(
id: b['id']! as String,
path: b['path']! as String,
dirty: (b['dirty'] as bool?) ?? false,
),
]; ];
notifyListeners(); notifyListeners();
} }
@@ -143,10 +138,7 @@ class EditorController extends ChangeNotifier {
} }
/// Called by the widget on every local text edit. /// Called by the widget on every local text edit.
void pushLocalEdit({ void pushLocalEdit({required String newContent, required Selection newSelection}) {
required String newContent,
required Selection newSelection,
}) {
final id = _activeId; final id = _activeId;
if (id == null) return; if (id == null) return;
@@ -162,11 +154,7 @@ class EditorController extends ChangeNotifier {
// large buffers, so event broadcasts stay small. // large buffers, so event broadcasts stay small.
_pendingLocalEdits++; _pendingLocalEdits++;
_suppressNextRemoteEdit = true; _suppressNextRemoteEdit = true;
ipc.request('editor.set-content', args: { ipc.request('editor.set-content', args: {'id': id, 'text': newContent, 'selection': newSelection.toJson()}).whenComplete(() => _pendingLocalEdits--);
'id': id,
'text': newContent,
'selection': newSelection.toJson(),
}).whenComplete(() => _pendingLocalEdits--);
} }
Future<void> save() async { Future<void> save() async {
+10 -19
View File
@@ -65,10 +65,7 @@ class _EditorViewState extends State<EditorView> {
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged); _controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
_keymap = kernel.keymap; _keymap = kernel.keymap;
_matcher = SequenceMatcher( _matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
keymap: () => kernel.keymap.keymap ?? Keymap(const []),
context: () => kernel.keymap.scope,
);
// Rebuild when the Vim mode flips so the editor toggles read-only. // Rebuild when the Vim mode flips so the editor toggles read-only.
kernel.keymap.addListener(_onModeChanged); kernel.keymap.addListener(_onModeChanged);
unawaited(_controller!.hydrate()); unawaited(_controller!.hydrate());
@@ -105,10 +102,7 @@ class _EditorViewState extends State<EditorView> {
_text.updatePath(c.activePath); _text.updatePath(c.activePath);
if (c.content != _lastRemoteContent) { if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content; _lastRemoteContent = c.content;
final sel = TextSelection( final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
baseOffset: c.selection.start.clamp(0, c.content.length),
extentOffset: c.selection.end.clamp(0, c.content.length),
);
_text.removeListener(_onTextChanged); _text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel); _text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged); _text.addListener(_onTextChanged);
@@ -306,9 +300,7 @@ class _EditorViewState extends State<EditorView> {
return ClidePaneChrome( return ClidePaneChrome(
title: 'editor', title: 'editor',
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree', subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
child: const Center( child: const Center(child: ClideText('Open a file to begin editing.', muted: true)),
child: ClideText('Open a file to begin editing.', muted: true),
),
); );
} }
return MultitabPane<String>( return MultitabPane<String>(
@@ -357,12 +349,7 @@ class _TextBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final style = TextStyle( final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
color: foreground,
fontSize: clideFontMono,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
);
final editable = EditableText( final editable = EditableText(
controller: controller, controller: controller,
focusNode: focus, focusNode: focus,
@@ -398,7 +385,10 @@ class _TextBody extends StatelessWidget {
/// Advance width of one monospace glyph in [style]. /// Advance width of one monospace glyph in [style].
static double _charWidth(TextStyle 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; return tp.width;
} }
} }
@@ -417,7 +407,8 @@ class _RulerPainter extends CustomPainter {
Offset(x, size.height), Offset(x, size.height),
Paint() Paint()
..color = color ..color = color
..strokeWidth = 1); ..strokeWidth = 1,
);
} }
@override @override
@@ -36,7 +36,10 @@ class SyntaxTextController extends TextEditingController {
if (source == _highlightedText) return; if (source == _highlightedText) return;
_highlighting = true; _highlighting = true;
_syntax.highlight(path, source).then((result) { _syntax
.highlight(path, source)
.then(
(result) {
_highlighting = false; _highlighting = false;
if (text != source) { if (text != source) {
_requestHighlight(); _requestHighlight();
@@ -45,9 +48,11 @@ class SyntaxTextController extends TextEditingController {
_highlightedText = source; _highlightedText = source;
_spans = result.spans; _spans = result.spans;
notifyListeners(); notifyListeners();
}, onError: (_) { },
onError: (_) {
_highlighting = false; _highlighting = false;
}); },
);
} }
@override @override
@@ -57,11 +62,7 @@ class SyntaxTextController extends TextEditingController {
} }
@override @override
TextSpan buildTextSpan({ TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final tokens = _tokens; final tokens = _tokens;
if (_spans.isEmpty || tokens == null || text.isEmpty) { if (_spans.isEmpty || tokens == null || text.isEmpty) {
return TextSpan(text: text, style: style); return TextSpan(text: text, style: style);
@@ -125,20 +126,17 @@ class SyntaxTextController extends TextEditingController {
// Gap before this span — plain text. // Gap before this span — plain text.
if (spanCharStart > charPos) { if (spanCharStart > charPos) {
children.add(TextSpan( children.add(TextSpan(text: source.substring(charPos, spanCharStart), style: style));
text: source.substring(charPos, spanCharStart),
style: style,
));
} }
// The highlighted span. // The highlighted span.
if (spanCharEnd > spanCharStart) { if (spanCharEnd > spanCharStart) {
children.add(TextSpan( children.add(
TextSpan(
text: source.substring(spanCharStart, spanCharEnd), text: source.substring(spanCharStart, spanCharEnd),
style: style?.copyWith( style: style?.copyWith(color: TreeSitterService.colorForRole(span.role, tokens)),
color: TreeSitterService.colorForRole(span.role, tokens),
), ),
)); );
} }
charPos = spanCharEnd; charPos = spanCharEnd;
@@ -146,10 +144,7 @@ class SyntaxTextController extends TextEditingController {
// Trailing plain text. // Trailing plain text.
if (charPos < source.length) { if (charPos < source.length) {
children.add(TextSpan( children.add(TextSpan(text: source.substring(charPos), style: style));
text: source.substring(charPos),
style: style,
));
} }
return TextSpan(style: style, children: children); return TextSpan(style: style, children: children);
+69 -23
View File
@@ -79,13 +79,7 @@ class VimResult {
/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual] /// Apply [action] to [v]. [count] repeats motions/line-edits; [visual]
/// selects between collapse-to-caret (normal) and extend-from-anchor /// selects between collapse-to-caret (normal) and extend-from-anchor
/// (visual) for motions, and enables the `visual*` range ops. /// (visual) for motions, and enables the `visual*` range ops.
VimResult applyVim( VimResult applyVim(String action, TextEditingValue v, {VimRegister register = VimRegister.empty, bool visual = false, int count = 1}) {
String action,
TextEditingValue v, {
VimRegister register = VimRegister.empty,
bool visual = false,
int count = 1,
}) {
final t = v.text; final t = v.text;
final caret = v.selection.extentOffset.clamp(0, t.length); final caret = v.selection.extentOffset.clamp(0, t.length);
final anchor = v.selection.baseOffset.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) { int _cls(String ch) {
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0;
final c = ch.codeUnitAt(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 >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z (c >= 0x61 && c <= 0x7A) || // a-z
c == 0x5F; // _ c == 0x5F; // _
@@ -279,11 +274,17 @@ int _wordEnd(String t, int off) {
// -- Edit helpers ----------------------------------------------------------- // -- Edit helpers -----------------------------------------------------------
VimResult _collapsed(String text, int caret) => VimResult( 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( 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, enterInsert: true,
); );
@@ -298,7 +299,10 @@ VimResult _deleteChar(String t, int caret, int count) {
final nle = _lineEnd(nt, caret); final nle = _lineEnd(nt, caret);
final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls); final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls);
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ncaret)), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ncaret),
),
register: VimRegister(removed), register: VimRegister(removed),
); );
} }
@@ -324,7 +328,10 @@ VimResult _deleteLines(String t, int caret, int count) {
caretLineStart = ls; caretLineStart = ls;
} }
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart))), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart)),
),
register: reg, register: reg,
); );
} }
@@ -337,7 +344,10 @@ VimResult _deleteToEnd(String t, int caret) {
final ls = _lineStart(nt, caret); final ls = _lineStart(nt, caret);
final nle = _lineEnd(nt, caret); final nle = _lineEnd(nt, caret);
return VimResult( 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), 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 removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, ''); final nt = t.replaceRange(lo, hi, '');
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed), register: VimRegister(removed),
enterInsert: insert, enterInsert: insert,
); );
@@ -366,7 +379,10 @@ VimResult _changeLine(String t, int caret, int count) {
final removed = t.substring(ls, end); final removed = t.substring(ls, end);
final nt = t.replaceRange(ls, end, ''); final nt = t.replaceRange(ls, end, '');
return VimResult( 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), register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true),
enterInsert: true, enterInsert: true,
); );
@@ -382,7 +398,10 @@ VimResult _yankLines(String t, int caret, int count) {
} }
final yanked = t.substring(ls, end); final yanked = t.substring(ls, end);
return VimResult( 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), 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) { if (before) {
final ls = _lineStart(t, caret); final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, body); 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 le = _lineEnd(t, caret);
final insertAt = le < t.length ? le + 1 : t.length; 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 chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}';
final nt = t.replaceRange(insertAt, insertAt, chunk); final nt = t.replaceRange(insertAt, insertAt, chunk);
final caretLine = le < t.length ? insertAt : insertAt + 1; 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. // Charwise: p pastes after the caret, P at the caret.
final at = before ? caret : _clamp(caret + 1, 0, t.length); final at = before ? caret : _clamp(caret + 1, 0, t.length);
final nt = t.replaceRange(at, at, reg.text); 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}) { 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 le = _lineEnd(t, caret);
final nt = t.replaceRange(le, le, '\n'); final nt = t.replaceRange(le, le, '\n');
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: le + 1)), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: le + 1),
),
enterInsert: true, enterInsert: true,
); );
} }
final ls = _lineStart(t, caret); final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, '\n'); final nt = t.replaceRange(ls, ls, '\n');
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ls),
),
enterInsert: true, enterInsert: true,
); );
} }
@@ -435,7 +475,10 @@ VimResult _deleteRange(String t, int anchor, int caret, {required bool insert})
final removed = t.substring(lo, hi); final removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, ''); final nt = t.replaceRange(lo, hi, '');
return VimResult( return VimResult(
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)), TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed), register: VimRegister(removed),
enterInsert: insert, enterInsert: insert,
); );
@@ -445,7 +488,10 @@ VimResult _yankRange(String t, int anchor, int caret) {
final lo = anchor < caret ? anchor : caret; final lo = anchor < caret ? anchor : caret;
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length); final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
return VimResult( return VimResult(
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: lo)), TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(t.substring(lo, hi)), register: VimRegister(t.substring(lo, hi)),
); );
} }
+12 -61
View File
@@ -50,17 +50,11 @@ class _FileTreeViewState extends State<FileTreeView> {
listenable: c, listenable: c,
builder: (context, _) { builder: (context, _) {
if (c.error != null && c.rootPath == null) { if (c.error != null && c.rootPath == null) {
return Padding( return Padding(padding: const EdgeInsets.all(12), child: ClideText(c.error!, muted: true));
padding: const EdgeInsets.all(12),
child: ClideText(c.error!, muted: true),
);
} }
final root = c.rootPath; final root = c.rootPath;
if (root == null) { if (root == null) {
return const Padding( return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
);
} }
final rootName = root.split(Platform.pathSeparator).last; final rootName = root.split(Platform.pathSeparator).last;
return Column( return Column(
@@ -98,18 +92,12 @@ class _FileTreeViewState extends State<FileTreeView> {
final matches = c.allLoadedEntries().where((e) { final matches = c.allLoadedEntries().where((e) {
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter); return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
}).toList(); }).toList();
return [ return [for (final e in matches) _FilteredFileRow(entry: e)];
for (final e in matches) _FilteredFileRow(entry: e),
];
} }
} }
class _Children extends StatelessWidget { class _Children extends StatelessWidget {
const _Children({ const _Children({required this.path, required this.controller, required this.depth});
required this.path,
required this.controller,
required this.depth,
});
final String path; final String path;
final FileTreeController controller; final FileTreeController controller;
@@ -129,33 +117,19 @@ class _Children extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_DirRow( _DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
name: e.name,
path: e.path,
controller: controller,
depth: depth,
),
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1), if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
], ],
) )
else else
_FileRow( _FileRow(name: e.name, path: e.path, depth: depth),
name: e.name,
path: e.path,
depth: depth,
),
], ],
); );
} }
} }
class _DirRow extends StatelessWidget { class _DirRow extends StatelessWidget {
const _DirRow({ const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
required this.name,
required this.path,
required this.controller,
required this.depth,
});
final String name; final String name;
final String path; final String path;
@@ -173,11 +147,7 @@ class _DirRow extends StatelessWidget {
child: _Row( child: _Row(
depth: depth, depth: depth,
onTap: () => controller.toggle(path), onTap: () => controller.toggle(path),
leading: ClideIcon( leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
const ChevronRightIcon(),
size: 10,
color: tokens.sidebarForeground,
),
label: name, label: name,
rotateLeading: expanded, rotateLeading: expanded,
), ),
@@ -186,11 +156,7 @@ class _DirRow extends StatelessWidget {
} }
class _FileRow extends StatelessWidget { class _FileRow extends StatelessWidget {
const _FileRow({ const _FileRow({required this.name, required this.path, required this.depth});
required this.name,
required this.path,
required this.depth,
});
final String name; final String name;
final String path; final String path;
@@ -202,11 +168,7 @@ class _FileRow extends StatelessWidget {
button: true, button: true,
label: 'Open $name', label: 'Open $name',
onTap: () => _openFile(context, path), onTap: () => _openFile(context, path),
child: _Row( child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
depth: depth,
onTap: () => _openFile(context, path),
label: name,
),
); );
} }
@@ -218,13 +180,7 @@ class _FileRow extends StatelessWidget {
} }
class _Row extends StatelessWidget { class _Row extends StatelessWidget {
const _Row({ const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
required this.depth,
required this.onTap,
required this.label,
this.leading,
this.rotateLeading = false,
});
final int depth; final int depth;
final VoidCallback onTap; final VoidCallback onTap;
@@ -252,12 +208,7 @@ class _Row extends StatelessWidget {
] else ] else
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: ClideText( child: ClideText(label, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
), ),
], ],
), ),
+1 -5
View File
@@ -26,10 +26,6 @@ class GitExtension extends ClideExtension {
priority: -80, priority: -80,
build: (_) => const GitPanelView(), build: (_) => const GitPanelView(),
), ),
StatusItemContribution( StatusItemContribution(id: 'git.branch', priority: 10, build: (_) => const GitStatusItem()),
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 { Future<bool> stash({String? message}) async {
final r = await ipc.request('git.stash', args: { final r = await ipc.request('git.stash', args: {'message': ?message});
if (message != null) 'message': message,
});
return r.ok; return r.ok;
} }
+37 -166
View File
@@ -87,57 +87,26 @@ class _GitPanelViewState extends State<GitPanelView> {
if (c.error != null) if (c.error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText( child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
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) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (!c.loading && c.isClean && c.error == null) if (!c.loading && c.isClean && c.error == null)
const Padding( const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
padding: EdgeInsets.all(12), if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
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) ...[ if (c.staged.isNotEmpty) ...[
_FileGroup( _FileGroup(
label: 'Staged', label: 'Staged',
entries: _applyFilter(c.staged), entries: _applyFilter(c.staged),
actions: [ actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
_GroupAction(
label: 'Unstage all',
onTap: () => unawaited(c.unstage(const [])),
),
],
onUnstage: (path) => unawaited(c.unstage([path])), onUnstage: (path) => unawaited(c.unstage([path])),
), ),
_CommitInput( _CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
commitMsg: _commitMsg,
commitFocus: _commitFocus,
controller: c,
),
], ],
if (c.unstaged.isNotEmpty) if (c.unstaged.isNotEmpty)
_FileGroup( _FileGroup(
label: 'Changes', label: 'Changes',
entries: _applyFilter(c.unstaged), entries: _applyFilter(c.unstaged),
actions: [ actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
_GroupAction(
label: 'Stage all',
onTap: () => unawaited(c.stageAll()),
),
],
onStage: (path) => unawaited(c.stage([path])), onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path), onDiscard: (path) => _confirmDiscard(context, c, path),
), ),
@@ -149,9 +118,7 @@ class _GitPanelViewState extends State<GitPanelView> {
_GroupAction( _GroupAction(
label: 'Stage all', label: 'Stage all',
onTap: () { onTap: () {
final paths = [ final paths = [for (final e in c.untracked) e['path'] as String];
for (final e in c.untracked) e['path'] as String,
];
unawaited(c.stage(paths)); unawaited(c.stage(paths));
}, },
), ),
@@ -160,7 +127,8 @@ class _GitPanelViewState extends State<GitPanelView> {
), ),
], ],
), ),
)), ),
),
], ],
), ),
); );
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ClideText( child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
),
_SmallAction(
label: 'Pull',
semanticsLabel: 'git pull',
onTap: () => unawaited(controller.pull()),
), ),
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
const SizedBox(width: 4), const SizedBox(width: 4),
_SmallAction( _SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
label: 'Push',
semanticsLabel: 'git push',
onTap: () => unawaited(controller.push()),
),
], ],
), ),
); );
@@ -209,11 +165,7 @@ class _BranchHeader extends StatelessWidget {
} }
class _CommitInput extends StatelessWidget { class _CommitInput extends StatelessWidget {
const _CommitInput({ const _CommitInput({required this.commitMsg, required this.commitFocus, required this.controller});
required this.commitMsg,
required this.commitFocus,
required this.controller,
});
final TextEditingController commitMsg; final TextEditingController commitMsg;
final FocusNode commitFocus; final FocusNode commitFocus;
@@ -232,19 +184,12 @@ class _CommitInput extends StatelessWidget {
label: 'commit message', label: 'commit message',
textField: true, textField: true,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
border: Border.all(color: tokens.globalBorder),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: EditableText( child: EditableText(
controller: commitMsg, controller: commitMsg,
focusNode: commitFocus, focusNode: commitFocus,
style: TextStyle( style: TextStyle(fontFamily: clideUiFamily, fontWeight: clideUiDefaultWeight, fontSize: clideFontCaption, color: tokens.globalForeground),
fontFamily: clideUiFamily,
fontWeight: clideUiDefaultWeight,
fontSize: clideFontCaption,
color: tokens.globalForeground,
),
cursorColor: tokens.globalFocus, cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus, backgroundCursorColor: tokens.globalFocus,
maxLines: 3, maxLines: 3,
@@ -268,9 +213,11 @@ class _CommitInput extends StatelessWidget {
void _doCommit() { void _doCommit() {
final msg = commitMsg.text.trim(); final msg = commitMsg.text.trim();
if (msg.isEmpty) return; if (msg.isEmpty) return;
unawaited(controller.commit(msg).then((hash) { unawaited(
controller.commit(msg).then((hash) {
if (hash != null) commitMsg.clear(); if (hash != null) commitMsg.clear();
})); }),
);
} }
} }
@@ -281,14 +228,7 @@ class _GroupAction {
} }
class _FileGroup extends StatelessWidget { class _FileGroup extends StatelessWidget {
const _FileGroup({ const _FileGroup({required this.label, required this.entries, this.actions = const [], this.onStage, this.onUnstage, this.onDiscard});
required this.label,
required this.entries,
this.actions = const [],
this.onStage,
this.onUnstage,
this.onDiscard,
});
final String label; final String label;
final List<Map<String, Object?>> entries; final List<Map<String, Object?>> entries;
@@ -309,39 +249,20 @@ class _FileGroup extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: ClideText( child: ClideText('$label (${entries.length})', fontSize: clideFontCaption, muted: true, color: tokens.sidebarForeground),
'$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) for (final entry in entries) _GitFileRow(entry: entry, onStage: onStage, onUnstage: onUnstage, onDiscard: onDiscard),
_GitFileRow(
entry: entry,
onStage: onStage,
onUnstage: onUnstage,
onDiscard: onDiscard,
),
], ],
); );
} }
} }
class _GitFileRow extends StatelessWidget { class _GitFileRow extends StatelessWidget {
const _GitFileRow({ const _GitFileRow({required this.entry, this.onStage, this.onUnstage, this.onDiscard});
required this.entry,
this.onStage,
this.onUnstage,
this.onDiscard,
});
final Map<String, Object?> entry; final Map<String, Object?> entry;
final void Function(String path)? onStage; 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), padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
child: Row( child: Row(
children: [ children: [
ClideText( ClideText(_stateIndicator(state), fontSize: clideFontCaption, color: _stateColor(state, tokens)),
_stateIndicator(state),
fontSize: clideFontCaption,
color: _stateColor(state, tokens),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: ClideText( child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
), ),
if (hovered) ...[ if (hovered) ...[
if (onStage != null) if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
_SmallAction( if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
label: '+', if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
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 { class _SmallAction extends StatelessWidget {
const _SmallAction({ const _SmallAction({required this.label, required this.onTap, this.semanticsLabel});
required this.label,
required this.onTap,
this.semanticsLabel,
});
final String label; final String label;
final String? semanticsLabel; final String? semanticsLabel;
@@ -467,11 +360,7 @@ class _SmallAction extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: ClideText( child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground),
label,
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
), ),
), ),
); );
@@ -479,11 +368,7 @@ class _SmallAction extends StatelessWidget {
} }
class _DiscardConfirmDialog extends StatelessWidget { class _DiscardConfirmDialog extends StatelessWidget {
const _DiscardConfirmDialog({ const _DiscardConfirmDialog({required this.path, required this.onConfirm, required this.onCancel});
required this.path,
required this.onConfirm,
required this.onCancel,
});
final String path; final String path;
final VoidCallback onConfirm; final VoidCallback onConfirm;
@@ -505,30 +390,16 @@ class _DiscardConfirmDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText( ClideText('Discard changes?', color: tokens.globalForeground),
'Discard changes?',
color: tokens.globalForeground,
),
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText( ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
'Unstaged changes to $name will be permanently lost.',
fontSize: clideFontCaption,
color: tokens.statusError,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ClideButton( ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
label: 'Cancel',
variant: ClideButtonVariant.subtle,
onPressed: onCancel,
),
const SizedBox(width: 8), const SizedBox(width: 8),
ClideButton( ClideButton(label: 'Discard', onPressed: onConfirm),
label: 'Discard',
onPressed: onConfirm,
),
], ],
), ),
], ],
+8 -40
View File
@@ -51,13 +51,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
void _openBranchPicker() { void _openBranchPicker() {
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
kernel.dialog.show<String>( kernel.dialog.show<String>((ctx, dismiss) => _BranchPicker(ipc: kernel.ipc, currentBranch: _branch, onDismiss: dismiss));
(ctx, dismiss) => _BranchPicker(
ipc: kernel.ipc,
currentBranch: _branch,
onDismiss: dismiss,
),
);
} }
@override @override
@@ -79,17 +73,9 @@ class _GitStatusItemState extends State<GitStatusItem> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClideIcon( ClideIcon(const GitBranchIcon(), size: 12, color: tokens.statusBarForeground),
const GitBranchIcon(),
size: 12,
color: tokens.statusBarForeground,
),
const SizedBox(width: 4), const SizedBox(width: 4),
ClideText( ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.statusBarForeground),
parts.join(' '),
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
], ],
), ),
), ),
@@ -100,11 +86,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
} }
class _BranchPicker extends StatefulWidget { class _BranchPicker extends StatefulWidget {
const _BranchPicker({ const _BranchPicker({required this.ipc, required this.currentBranch, required this.onDismiss});
required this.ipc,
required this.currentBranch,
required this.onDismiss,
});
final DaemonClient ipc; final DaemonClient ipc;
final String? currentBranch; final String? currentBranch;
@@ -139,9 +121,7 @@ class _BranchPickerState extends State<_BranchPicker> {
setState(() { setState(() {
_loading = false; _loading = false;
if (r.ok) { if (r.ok) {
_branches = [ _branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>()];
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
];
} else { } else {
_error = r.error?.message ?? 'failed to load branches'; _error = r.error?.message ?? 'failed to load branches';
} }
@@ -210,11 +190,7 @@ class _BranchPickerState extends State<_BranchPicker> {
final b = _branches[i]; final b = _branches[i];
final name = b['name'] as String? ?? ''; final name = b['name'] as String? ?? '';
final current = b['current'] as bool? ?? false; final current = b['current'] as bool? ?? false;
return _BranchRow( return _BranchRow(name: name, current: current, onTap: current ? null : () => unawaited(_checkout(name)));
name: name,
current: current,
onTap: current ? null : () => unawaited(_checkout(name)),
);
}, },
), ),
), ),
@@ -226,11 +202,7 @@ class _BranchPickerState extends State<_BranchPicker> {
} }
class _BranchRow extends StatelessWidget { class _BranchRow extends StatelessWidget {
const _BranchRow({ const _BranchRow({required this.name, required this.current, this.onTap});
required this.name,
required this.current,
this.onTap,
});
final String name; final String name;
final bool current; final bool current;
@@ -250,11 +222,7 @@ class _BranchRow extends StatelessWidget {
if (current) if (current)
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ClideIcon( child: ClideIcon(const CheckIcon(), size: 12, color: tokens.statusSuccess),
const CheckIcon(),
size: 12,
color: tokens.statusSuccess,
),
) )
else else
const SizedBox(width: 20), const SizedBox(width: 20),
+6 -6
View File
@@ -26,9 +26,12 @@ class _GraphViewState extends State<GraphView> {
Future<void> _load() async { Future<void> _load() async {
final kernel = ClideKernel.of(context); 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'], 'argv': ['search', '--connections', '--limit', '50'],
}); },
);
if (!mounted) return; if (!mounted) return;
if (!resp.ok) { if (!resp.ok) {
setState(() { setState(() {
@@ -62,10 +65,7 @@ class _GraphViewState extends State<GraphView> {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
} }
if (_nodes.isEmpty) { if (_nodes.isEmpty) {
return const Padding( return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
padding: EdgeInsets.all(12),
child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true),
);
} }
return ListView.builder( return ListView.builder(
itemCount: _nodes.length, itemCount: _nodes.length,
+1 -7
View File
@@ -10,11 +10,5 @@ class IpcStatusExtension extends ClideExtension {
String get version => '0.2.0'; String get version => '0.2.0';
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [StatusItemContribution(id: 'ipc-status.indicator', priority: 100, build: (_) => const ToolStatusItem())];
StatusItemContribution(
id: 'ipc-status.indicator',
priority: 100,
build: (_) => const ToolStatusItem(),
),
];
} }
+5 -1
View File
@@ -36,7 +36,11 @@ class ToolStatusItem extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 6),
ClideText(label, fontSize: clideFontCaption, color: color), 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>` /// Presets that ship today, each exposed as a `keymap.preset.<name>`
/// command that activates it. /// command that activates it.
static const _presets = <String, String>{ static const _presets = <String, String>{'default': 'Keymap: Default', 'vim': 'Keymap: Vim', 'vscode': 'Keymap: VS Code', 'jetbrains': 'Keymap: JetBrains'};
'default': 'Keymap: Default',
'vim': 'Keymap: Vim',
'vscode': 'Keymap: VS Code',
'jetbrains': 'Keymap: JetBrains',
};
@override @override
List<ContributionPoint> get contributions => [ 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)); return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
} }
if (_content == null) { if (_content == null) {
return const Padding( return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a .md file to preview it here.', muted: true));
padding: EdgeInsets.all(12),
child: ClideText('Select a .md file to preview it here.', muted: true),
);
} }
return ClidePaneChrome( return ClidePaneChrome(
title: _path ?? 'viewer', title: _path ?? 'viewer',
subtitle: '${_content!.split('\n').length} lines', subtitle: '${_content!.split('\n').length} lines',
leading: ReaderPinButton( leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _path != null ? _onPin : null),
pinned: _nav?.hasPinned ?? false,
onTap: _path != null ? _onPin : null,
),
trailing: [ trailing: [
ReaderActionBar( ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false, canGoBack: _nav?.canGoBack ?? false,
+7 -2
View File
@@ -66,7 +66,9 @@ class _Kv extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)), 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; final deps = snap.data!.dependencies;
return Container( return Container(
constraints: const BoxConstraints(maxHeight: 260), 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( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
+17 -7
View File
@@ -68,13 +68,20 @@ class MenuBarExtension extends ClideExtension {
/// The curated File / View / Help tree (T-48). View ends with a `view.*` /// The curated File / View / Help tree (T-48). View ends with a `view.*`
/// auto-fill so newly-registered view commands surface without edits here. /// auto-fill so newly-registered view commands surface without edits here.
List<TopMenu> buildClideMenuTree() => [ 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.openFolder', fallbackTitle: 'Open Folder…'),
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'), const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
const MenuSeparator(), const MenuSeparator(),
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen), 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.zoomIn'),
MenuCommandItem('view.zoomOut'), MenuCommandItem('view.zoomOut'),
MenuCommandItem('view.zoomReset'), MenuCommandItem('view.zoomReset'),
@@ -85,8 +92,11 @@ List<TopMenu> buildClideMenuTree() => [
MenuCommandItem('panel.focusMode'), MenuCommandItem('panel.focusMode'),
MenuSeparator(), MenuSeparator(),
MenuAutoFill('view.'), 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()), onSubmitted: (_) => unawaited(_submit()),
), ),
), ),
if (_error != null) ...[ if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: 12)],
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: 12),
],
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@@ -188,17 +185,11 @@ class NotARepoDialog extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13), ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8), const SizedBox(height: 8),
const ClideText( const ClideText('A clide project root requires a git repository.', muted: true, fontSize: 13),
'A clide project root requires a git repository.',
muted: true,
fontSize: 13,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
), ),
], ],
), ),
+3 -14
View File
@@ -72,18 +72,11 @@ class MenuBar extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: Listenable.merge([controller, kernel.commands, kernel.project]), listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
builder: (ctx, _) { builder: (ctx, _) {
final menus = resolveMenus( final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id));
buildClideMenuTree(),
kernel.commands,
kernel,
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
);
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]); controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel)],
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, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard), padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
color: open || hovered ? tokens.listItemHoverBackground : null, color: open || hovered ? tokens.listItemHoverBackground : null,
child: ClideText( child: ClideText(widget.menu.title, fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
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 /// menus. [bindingLabel] supplies the keybinding string for a command id
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null /// (typically [keymapBindingLabel] bound to the keymap); when it returns null
/// the command's own `defaultBinding` is used as a fallback. /// the command's own `defaultBinding` is used as a fallback.
List<ResolvedMenu> resolveMenus( List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
List<TopMenu> tree,
CommandRegistry registry,
KernelServices services, {
String? Function(String commandId)? bindingLabel,
}) {
final placed = <String>{ final placed = <String>{
for (final m in tree) for (final m in tree)
for (final n in m.nodes) for (final n in m.nodes)
@@ -142,9 +137,7 @@ List<ResolvedMenu> resolveMenus(
List<ResolvedNode> expand(MenuNode n) => switch (n) { List<ResolvedNode> expand(MenuNode n) => switch (n) {
MenuCommandItem() => [resolveItem(n)], MenuCommandItem() => [resolveItem(n)],
MenuSeparator() => const [ResolvedSeparator()], MenuSeparator() => const [ResolvedSeparator()],
MenuAutoFill(:final prefix) => [ MenuAutoFill(:final prefix) => [for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command))],
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
],
}; };
return [ return [
+12 -20
View File
@@ -84,10 +84,7 @@ class _OutputViewState extends State<OutputView> {
child: rows.isEmpty child: rows.isEmpty
? Padding( ? Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ClideText( child: ClideText(widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', muted: true),
widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.',
muted: true,
),
) )
: Stack( : Stack(
children: [ children: [
@@ -100,12 +97,7 @@ class _OutputViewState extends State<OutputView> {
children: [for (final r in rows) _LogRow(record: r)], children: [for (final r in rows) _LogRow(record: r)],
), ),
), ),
if (!_following) if (!_following) Positioned(right: 12, bottom: 8, child: _JumpPill(onTap: _jumpToLatest)),
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), child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_Chip( _Chip(label: 'Level: ${_c.minLevel.name}', onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length])),
label: 'Level: ${_c.minLevel.name}',
onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]),
),
const SizedBox(width: 6), const SizedBox(width: 6),
_Chip( _Chip(label: 'Source: ${_c.source ?? 'all'}', onTap: _cycleSource),
label: 'Source: ${_c.source ?? 'all'}',
onTap: _cycleSource,
),
const SizedBox(width: 6), const SizedBox(width: 6),
_Chip(label: 'Clear', onTap: _c.clear), _Chip(label: 'Clear', onTap: _c.clear),
], ],
@@ -235,8 +221,14 @@ class _LogRow extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 92, width: 92,
child: ClideText(record.source, child: ClideText(
fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip), record.source,
fontSize: clideFontMono,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.clip,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
+8 -41
View File
@@ -42,13 +42,7 @@ class _BacklinksViewState extends State<BacklinksView> {
builder: (context, _) { builder: (context, _) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
if (c.activePath == null) { if (c.activePath == null) {
return const Padding( return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
padding: EdgeInsets.all(12),
child: ClideText(
'Open a file to see its links.',
muted: true,
),
);
} }
return Semantics( return Semantics(
label: 'backlinks for ${c.activePath}', label: 'backlinks for ${c.activePath}',
@@ -62,35 +56,16 @@ class _BacklinksViewState extends State<BacklinksView> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText( child: ClideText(c.activePath!.split('/').last, color: tokens.globalForeground),
c.activePath!.split('/').last,
color: tokens.globalForeground,
),
), ),
if (c.error != null) if (c.error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText( child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
c.error!,
color: tokens.statusError,
fontSize: clideFontCaption,
),
),
if (c.loading)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
_LinkGroup(
label: 'Backlinks',
links: c.backlinks,
pathKey: 'source',
),
_LinkGroup(
label: 'Outlinks',
links: c.outlinks,
pathKey: 'target',
), ),
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
], ],
), ),
), ),
@@ -101,11 +76,7 @@ class _BacklinksViewState extends State<BacklinksView> {
} }
class _LinkGroup extends StatelessWidget { class _LinkGroup extends StatelessWidget {
const _LinkGroup({ const _LinkGroup({required this.label, required this.links, required this.pathKey});
required this.label,
required this.links,
required this.pathKey,
});
final String label; final String label;
final List<Map<String, Object?>> links; final List<Map<String, Object?>> links;
@@ -119,11 +90,7 @@ class _LinkGroup extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2), padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
child: ClideText( child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
'$label (${links.length})',
fontSize: clideFontCaption,
muted: true,
),
), ),
if (links.isEmpty) if (links.isEmpty)
const Padding( const Padding(
+3 -12
View File
@@ -71,10 +71,7 @@ class PqlController extends ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
final r = await ipc.request('pql.search', args: { final r = await ipc.request('pql.search', args: {'terms': terms, 'limit': 50});
'terms': terms,
'limit': 50,
});
_loading = false; _loading = false;
if (!r.ok) { if (!r.ok) {
@@ -91,10 +88,7 @@ class PqlController extends ChangeNotifier {
_loading = true; _loading = true;
notifyListeners(); notifyListeners();
final r = await ipc.request('pql.files', args: { final r = await ipc.request('pql.files', args: {'glob': glob ?? '**/*.md', 'limit': 200});
'glob': glob ?? '**/*.md',
'limit': 200,
});
_loading = false; _loading = false;
if (!r.ok) { if (!r.ok) {
@@ -113,10 +107,7 @@ class PqlController extends ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
final r = await ipc.request('pql.query', args: { final r = await ipc.request('pql.query', args: {'query': dsl, 'limit': 200});
'query': dsl,
'limit': 200,
});
_loading = false; _loading = false;
if (!r.ok) { if (!r.ok) {
+2 -11
View File
@@ -187,10 +187,7 @@ class _SearchResultRow extends StatelessWidget {
}, },
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(color: hovered ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(4)),
color: hovered ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(4),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -259,13 +256,7 @@ class _FileRow extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null, border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null,
), ),
child: ClideText( child: ClideText(path, maxLines: 1, overflow: TextOverflow.ellipsis, fontSize: clideFontCaption, color: tokens.sidebarForeground),
path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
), ),
), ),
); );
@@ -16,11 +16,7 @@ class Problem {
final String message; final String message;
final String? hint; final String? hint;
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {'source': source, 'message': message, if (hint != null) 'hint': hint};
'source': source,
'message': message,
if (hint != null) 'hint': hint,
};
} }
class ProblemsController extends ChangeNotifier { class ProblemsController extends ChangeNotifier {
@@ -47,11 +43,7 @@ class ProblemsController extends ChangeNotifier {
if (doctor.ok) { if (doctor.ok) {
final db = (doctor.data['db'] as Map?)?.cast<String, Object?>(); final db = (doctor.data['db'] as Map?)?.cast<String, Object?>();
if (db != null && db['exists'] == false) { if (db != null && db['exists'] == false) {
found.add(const Problem( found.add(const Problem(source: 'pql', message: 'pql index database not found', hint: 'Run pql to build the index.'));
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?>(); final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
if (skill != null) { if (skill != null) {
@@ -59,37 +51,21 @@ class ProblemsController extends ChangeNotifier {
if (project != null) { if (project != null) {
final state = project['state'] as String?; final state = project['state'] as String?;
if (state == 'stale') { if (state == 'stale') {
found.add(const Problem( found.add(const Problem(source: 'pql', message: 'pql skill is stale — newer version available', hint: 'Run: pql skill install'));
source: 'pql',
message: 'pql skill is stale — newer version available',
hint: 'Run: pql skill install',
));
} else if (state == 'missing') { } else if (state == 'missing') {
found.add(const Problem( found.add(const Problem(source: 'pql', message: 'pql skill not installed', hint: 'Run: pql init --with-skill=yes'));
source: 'pql',
message: 'pql skill not installed',
hint: 'Run: pql init --with-skill=yes',
));
} }
} }
} }
} else { } else {
found.add(Problem( found.add(Problem(source: 'pql', message: 'pql doctor failed', hint: doctor.error?.message));
source: 'pql',
message: 'pql doctor failed',
hint: doctor.error?.message,
));
} }
final sync = await ipc.request('pql.decisions.sync'); final sync = await ipc.request('pql.decisions.sync');
if (sync.ok) { if (sync.ok) {
final broken = (sync.data['broken'] as num?)?.toInt() ?? 0; final broken = (sync.data['broken'] as num?)?.toInt() ?? 0;
if (broken > 0) { if (broken > 0) {
found.add(Problem( found.add(Problem(source: 'decisions', message: '$broken broken cross-reference(s) in governance/', hint: 'Run: pql decisions validate'));
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, explicitChildNodes: true,
child: () { child: () {
final lf = _filter.toLowerCase(); final lf = _filter.toLowerCase();
final filtered = final filtered = lf.isEmpty
lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList(); ? c.problems
: c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -59,14 +60,18 @@ class _ProblemsViewState extends State<ProblemsView> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row( child: Row(
children: [ children: [
Expanded(child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground)), Expanded(
child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
Semantics( Semantics(
button: true, button: true,
label: 'refresh problems', label: 'refresh problems',
child: GestureDetector( child: GestureDetector(
onTap: () => unawaited(c.refresh()), onTap: () => unawaited(c.refresh()),
child: MouseRegion( 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: [ children: [
Row( Row(
children: [ children: [
ClideText( ClideText(problem.source, fontSize: clideFontMono, color: tokens.statusWarning, fontFamily: clideMonoFamily),
problem.source,
fontSize: clideFontMono,
color: tokens.statusWarning,
fontFamily: clideMonoFamily,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(child: ClideText(problem.message, color: tokens.sidebarForeground, maxLines: 2)),
child: ClideText(
problem.message,
color: tokens.sidebarForeground,
maxLines: 2,
),
),
], ],
), ),
if (problem.hint != null) if (problem.hint != null)
Padding( Padding(
padding: const EdgeInsets.only(left: 44, top: 2), padding: const EdgeInsets.only(left: 44, top: 2),
child: ClideText( child: ClideText(problem.hint!, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
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. /// results. Returns the number of files changed and matches replaced.
/// Callers must gate on [isWorkingTreeClean] + user confirmation first. /// Callers must gate on [isWorkingTreeClean] + user confirmation first.
Future<({int files, int count})> applyReplace() async { 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, 'pattern': pattern,
'regex': regex, 'regex': regex,
'ignoreCase': ignoreCase, 'ignoreCase': ignoreCase,
@@ -94,7 +96,8 @@ class FindInFilesController extends ChangeNotifier {
'exclude': _split(excludeGlobs), 'exclude': _split(excludeGlobs),
'replacement': replacement, 'replacement': replacement,
'apply': true, 'apply': true,
}); },
);
final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0; final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0;
final count = (r.data['totalCount'] as num?)?.toInt() ?? 0; final count = (r.data['totalCount'] as num?)?.toInt() ?? 0;
await run(pattern); // refresh the match list against the new content await run(pattern); // refresh the match list against the new content
@@ -123,13 +126,10 @@ class FindInFilesController extends ChangeNotifier {
_running = true; _running = true;
notifyListeners(); notifyListeners();
final resp = await ipc.request('search.grep', args: { final resp = await ipc.request(
'pattern': pattern, 'search.grep',
'regex': regex, args: {'pattern': pattern, 'regex': regex, 'ignoreCase': ignoreCase, 'include': _split(includeGlobs), 'exclude': _split(excludeGlobs)},
'ignoreCase': ignoreCase, );
'include': _split(includeGlobs),
'exclude': _split(excludeGlobs),
});
if (!resp.ok) { if (!resp.ok) {
_error = resp.error?.message ?? 'search failed'; _error = resp.error?.message ?? 'search failed';
_running = false; _running = false;
+50 -64
View File
@@ -50,17 +50,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
if (c.replacement.isEmpty || c.matchCount == 0) return; if (c.replacement.isEmpty || c.matchCount == 0) return;
final dialog = ClideKernel.of(context).dialog; final dialog = ClideKernel.of(context).dialog;
if (!await c.isWorkingTreeClean()) { if (!await c.isWorkingTreeClean()) {
await dialog.show<Object>((ctx, dismiss) => _MessageDialog( await dialog.show<Object>(
title: 'Working tree not clean', (ctx, dismiss) =>
body: 'Commit or stash your changes before replacing — git is the only undo.', _MessageDialog(title: 'Working tree not clean', body: 'Commit or stash your changes before replacing — git is the only undo.', dismiss: dismiss),
dismiss: dismiss, );
));
return; return;
} }
final confirmed = await dialog.show<bool>((ctx, dismiss) => _ConfirmDialog( final confirmed = await dialog.show<bool>(
body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', (ctx, dismiss) =>
dismiss: 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; if (confirmed != true) return;
await c.applyReplace(); await c.applyReplace();
} }
@@ -139,13 +138,15 @@ class _SearchPanelViewState extends State<SearchPanelView> {
children: [ children: [
Expanded( Expanded(
child: ClideFilterBox( child: ClideFilterBox(
address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)), address: 'search.findInFiles.replace',
const SizedBox(width: 6), hint: 'Replace',
_ReplaceAllButton( icon: null,
enabled: c.replacement.isNotEmpty && c.matchCount > 0, debounce: Duration.zero,
tokens: tokens, onChanged: c.setReplacement,
onTap: _replaceAll,
), ),
),
const SizedBox(width: 6),
_ReplaceAllButton(enabled: c.replacement.isNotEmpty && c.matchCount > 0, tokens: tokens, onTap: _replaceAll),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -154,10 +155,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
hint: 'files to include (e.g. *.dart)', hint: 'files to include (e.g. *.dart)',
icon: null, icon: null,
debounce: Duration.zero, debounce: Duration.zero,
onChanged: (v) => c.include = v), onChanged: (v) => c.include = v,
),
const SizedBox(height: 4), const SizedBox(height: 4),
ClideFilterBox( 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, padding: EdgeInsets.zero,
children: [ children: [
for (final entry in groups.entries) for (final entry in groups.entries)
_FileGroup( _FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch, query: c.query, replacement: c.replacement),
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 SurfaceTokens tokens;
final ValueChanged<SearchTabMode> onSelect; final ValueChanged<SearchTabMode> onSelect;
static const _labels = { static const _labels = {SearchTabMode.find: 'Find', SearchTabMode.vault: 'Vault', SearchTabMode.query: 'Query', SearchTabMode.markdown: 'Markdown'};
SearchTabMode.find: 'Find',
SearchTabMode.vault: 'Vault',
SearchTabMode.query: 'Query',
SearchTabMode.markdown: 'Markdown',
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), 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( child: Row(
children: [ children: [
for (final m in SearchTabMode.values) ...[ for (final m in SearchTabMode.values) ...[
@@ -233,13 +230,7 @@ class _ModeSwitcher extends StatelessWidget {
} }
class _Toggle extends StatelessWidget { class _Toggle extends StatelessWidget {
const _Toggle({ const _Toggle({required this.label, required this.tooltip, required this.active, required this.tokens, required this.onTap});
required this.label,
required this.tooltip,
required this.active,
required this.tokens,
required this.onTap,
});
final String label; final String label;
final String tooltip; final String tooltip;
@@ -296,14 +287,7 @@ class _StatusText extends StatelessWidget {
} }
class _FileGroup extends StatelessWidget { class _FileGroup extends StatelessWidget {
const _FileGroup({ const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap, required this.query, required this.replacement});
required this.path,
required this.matches,
required this.tokens,
required this.onTap,
required this.query,
required this.replacement,
});
final String path; final String path;
final List<SearchMatch> matches; final List<SearchMatch> matches;
@@ -336,13 +320,7 @@ class _FileGroup extends StatelessWidget {
} }
class _MatchRow extends StatelessWidget { class _MatchRow extends StatelessWidget {
const _MatchRow({ const _MatchRow({required this.match, required this.tokens, required this.onTap, required this.query, required this.replacement});
required this.match,
required this.tokens,
required this.onTap,
required this.query,
required this.replacement,
});
final SearchMatch match; final SearchMatch match;
final SurfaceTokens tokens; final SurfaceTokens tokens;
@@ -385,11 +363,17 @@ class _MatchRow extends StatelessWidget {
return RichText( return RichText(
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
text: TextSpan(style: _base, children: [ text: TextSpan(
style: _base,
children: [
TextSpan(text: line.substring(0, start)), 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)), TextSpan(text: line.substring(end)),
]), ],
),
); );
} }
@@ -403,12 +387,18 @@ class _MatchRow extends StatelessWidget {
RichText( RichText(
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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( RichText(
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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), border: Border.all(color: tokens.buttonBorder),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: ClideText( child: ClideText('Replace all', fontSize: clideFontCaption, color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted),
'Replace all',
fontSize: clideFontCaption,
color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted,
),
), ),
), ),
); );
+5 -33
View File
@@ -50,13 +50,7 @@ class ReaderActionBar extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_ActionButton( _ActionButton(painter: PhosphorIcons.byName('caret-left'), tooltip: 'Back', enabled: canGoBack, onTap: canGoBack ? onBack : null, tokens: tokens),
painter: PhosphorIcons.byName('caret-left'),
tooltip: 'Back',
enabled: canGoBack,
onTap: canGoBack ? onBack : null,
tokens: tokens,
),
const SizedBox(width: 2), const SizedBox(width: 2),
_ActionButton( _ActionButton(
painter: PhosphorIcons.byName('caret-right'), painter: PhosphorIcons.byName('caret-right'),
@@ -67,23 +61,11 @@ class ReaderActionBar extends StatelessWidget {
), ),
if (hasPinned) ...[ if (hasPinned) ...[
const SizedBox(width: 2), const SizedBox(width: 2),
_ActionButton( _ActionButton(painter: PhosphorIcons.byName('arrow-u-up-left'), tooltip: 'Jump to pin', enabled: true, onTap: onJumpToPin, tokens: tokens),
painter: PhosphorIcons.byName('arrow-u-up-left'),
tooltip: 'Jump to pin',
enabled: true,
onTap: onJumpToPin,
tokens: tokens,
),
], ],
if (onEdit != null) ...[ if (onEdit != null) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
_ActionButton( _ActionButton(painter: PhosphorIcons.byName('pencil-simple'), tooltip: 'Edit in editor', enabled: true, onTap: onEdit, tokens: tokens),
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 { class _ActionButton extends StatelessWidget {
const _ActionButton({ const _ActionButton({required this.painter, required this.tooltip, required this.enabled, required this.onTap, required this.tokens, this.active = false});
required this.painter,
required this.tooltip,
required this.enabled,
required this.onTap,
required this.tokens,
this.active = false,
});
final ClideIconPainter painter; final ClideIconPainter painter;
final String tooltip; final String tooltip;
@@ -161,10 +136,7 @@ class _ActionButton extends StatelessWidget {
width: 20, width: 20,
height: 20, height: 20,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(color: hovered && enabled ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(3)),
color: hovered && enabled ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(3),
),
child: ClideIcon(painter, size: 11, color: color), 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 shell = Platform.environment['SHELL'] ?? '/bin/bash';
final cwd = Directory.current.path; final cwd = Directory.current.path;
final response = await ipc.request('pane.spawn', args: { final response = await ipc.request(
'pane.spawn',
args: {
'argv': [shell, '-l'], 'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire, 'kind': PaneKind.terminal.wire,
'cwd': cwd, 'cwd': cwd,
'cols': _terminal.viewWidth, 'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight, 'rows': _terminal.viewHeight,
}); },
);
if (!mounted) return; if (!mounted) return;
if (!response.ok) { if (!response.ok) {
setState(() => _error = response.error?.message ?? 'spawn failed'); 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) { void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
final id = _paneId; final id = _paneId;
if (id == null) return; if (id == null) return;
_kernelIpc()?.request('pane.resize', args: { _kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
'id': id,
'cols': cols,
'rows': rows,
});
} }
DaemonClient? _kernelIpc() => _kernel()?.ipc; DaemonClient? _kernelIpc() => _kernel()?.ipc;
@@ -144,12 +143,7 @@ class _TerminalPaneState extends State<TerminalPane> {
return ClidePaneChrome( return ClidePaneChrome(
title: 'terminal', title: 'terminal',
subtitle: subtitle, subtitle: subtitle,
child: _error != null child: _error != null ? _ErrorBody(message: _error!) : ClidePtyView(terminal: _terminal, label: 'terminal — $subtitle'),
? _ErrorBody(message: _error!)
: ClidePtyView(
terminal: _terminal,
label: 'terminal — $subtitle',
),
); );
} }
} }
@@ -165,11 +159,7 @@ class _ErrorBody extends StatelessWidget {
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [const ClideText('Terminal unavailable'), const SizedBox(height: 4), ClideText(message, muted: true)],
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` // Opens the settings modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it); ⌘K opens // (the welcome theme-link and other callers reference it); ⌘K opens
// Settings, whose only section today is the theme picker. // Settings, whose only section today is the theme picker.
CommandContribution( CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick),
id: 'theme.pick',
command: 'theme.pick',
title: 'Settings…',
defaultBinding: 'ctrl+k',
run: _pick,
),
// Always-visible switcher in the far-right status bar (T-234). // Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after // priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right. // ipc-status so it sits to its right.
StatusItemContribution( StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
id: 'theme-picker.switcher',
priority: 110,
build: (_) => const ThemeSwitcherStatusItem(),
),
]; ];
Future<IpcResponse> _pick(List<String> args) async { Future<IpcResponse> _pick(List<String> args) async {
@@ -45,21 +35,10 @@ class ThemePickerExtension extends ClideExtension {
if (ctx == null) { if (ctx == null) {
return IpcResponse.err( return IpcResponse.err(
id: '', id: '',
error: IpcError( error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'theme-picker not activated'),
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'theme-picker not activated',
),
); );
} }
final selected = await ctx.dialog.show<String>( final selected = await ctx.dialog.show<String>((context, dismiss) => SettingsView(controller: ctx.theme, onDismiss: dismiss));
(context, dismiss) => SettingsView( return IpcResponse.ok(id: '', data: {'selected': selected ?? ctx.theme.currentName});
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. /// (D-69); the shared `theme_families` helpers keep both surfaces in sync.
/// Selecting a theme applies it live and dismisses; Cancel just closes. /// Selecting a theme applies it live and dismisses; Cancel just closes.
class SettingsView extends StatefulWidget { class SettingsView extends StatefulWidget {
const SettingsView({ const SettingsView({super.key, required this.controller, required this.onDismiss});
super.key,
required this.controller,
required this.onDismiss,
});
final ThemeController controller; final ThemeController controller;
final void Function([String? selected]) onDismiss; final void Function([String? selected]) onDismiss;
@@ -233,9 +229,7 @@ class _ThemeRow extends StatelessWidget {
) )
else else
const SizedBox(width: 20), const SizedBox(width: 20),
Expanded( Expanded(child: ClideText(displayName, color: fg)),
child: ClideText(displayName, color: fg),
),
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption), ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
], ],
), ),
@@ -129,19 +129,9 @@ class _ThemePopoverState extends State<_ThemePopover> {
maxWidth: 280, maxWidth: 280,
maxHeight: 360, maxHeight: 360,
entries: [ entries: [
ClideMenuItem( ClideMenuItem(label: 'High contrast', active: _hc, keepOpenOnSelect: true, onSelect: _toggleHc),
label: 'High contrast',
active: _hc,
keepOpenOnSelect: true,
onSelect: _toggleHc,
),
const ClideMenuSeparator(), const ClideMenuSeparator(),
for (final t in _themes) for (final t in _themes) ClideMenuItem(label: t.displayName, active: t.name == currentBase, onSelect: () => _pick(t)),
ClideMenuItem(
label: t.displayName,
active: t.name == currentBase,
onSelect: () => _pick(t),
),
], ],
), ),
); );
+1 -7
View File
@@ -1,13 +1,7 @@
import 'dart:ui' show Color; import 'dart:ui' show Color;
class TicketTypeColors { class TicketTypeColors {
const TicketTypeColors({ const TicketTypeColors({required this.initiative, required this.epic, required this.story, required this.task, required this.bug});
required this.initiative,
required this.epic,
required this.story,
required this.task,
required this.bug,
});
final Color initiative; final Color initiative;
final Color epic; final Color epic;
+23 -17
View File
@@ -73,10 +73,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
return ClidePaneChrome( return ClidePaneChrome(
title: d.id, title: d.id,
subtitle: d.title, subtitle: d.title,
leading: ReaderPinButton( leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _onPin),
pinned: _nav?.hasPinned ?? false,
onTap: _onPin,
),
trailing: [ trailing: [
ReaderActionBar( ReaderActionBar(
canGoBack: _nav?.canGoBack ?? false, canGoBack: _nav?.canGoBack ?? false,
@@ -104,13 +101,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
const SizedBox(height: 16), const SizedBox(height: 16),
_SectionLabel(label: 'PARENT TREE', tokens: tokens), _SectionLabel(label: 'PARENT TREE', tokens: tokens),
const SizedBox(height: 6), const SizedBox(height: 6),
for (var i = 0; i < d.parents.length; i++) for (var i = 0; i < d.parents.length; i++) _CompactCard(data: d.parents[i], tokens: tokens, typeColors: typeColors, indent: i),
_CompactCard(
data: d.parents[i],
tokens: tokens,
typeColors: typeColors,
indent: i,
),
], ],
if (d.decisions.isNotEmpty) ...[ if (d.decisions.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -150,7 +141,11 @@ class _TicketHeader extends StatelessWidget {
children: [ children: [
ClideTooltip( ClideTooltip(
message: detail.type ?? 'task', 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), const SizedBox(width: 8),
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily), ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
@@ -188,10 +183,13 @@ class _StatusControls extends StatelessWidget {
onTap: detail.status == s onTap: detail.status == s
? null ? null
: () async { : () async {
final resp = await controller.ipc.request('pql.tickets.status', args: { final resp = await controller.ipc.request(
'pql.tickets.status',
args: {
'ids': [detail.id], 'ids': [detail.id],
'status': s 'status': s,
}); },
);
if (resp.ok) { if (resp.ok) {
controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id}); controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id});
await controller.load(detail.id); await controller.load(detail.id);
@@ -266,7 +264,11 @@ class _CompactCard extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 6),
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -312,7 +314,11 @@ class _DecisionRefCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 6),
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily), ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily),
const Spacer(), 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 /// 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. /// the last one snaps all back on, so the list is never mysteriously blank.
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'}; static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
static const _typeOrder = [ static const _typeOrder = [('initiative', 'Initiative'), ('epic', 'Epic'), ('story', 'Story'), ('task', 'Task'), ('bug', 'Bug')];
('initiative', 'Initiative'),
('epic', 'Epic'),
('story', 'Story'),
('task', 'Task'),
('bug', 'Bug'),
];
final Set<String> _enabledTypes = {..._allTypes}; final Set<String> _enabledTypes = {..._allTypes};
StreamSubscription<Message>? _focusSub; StreamSubscription<Message>? _focusSub;
StreamSubscription<SchedulerTick>? _schedulerSub; StreamSubscription<SchedulerTick>? _schedulerSub;
@@ -107,9 +101,11 @@ class _TicketsViewState extends State<TicketsView> {
_focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus); _focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus);
_changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) { _changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) {
final id = msg.data['id'] as String?; final id = msg.data['id'] as String?;
unawaited(_refresh().then((_) { unawaited(
_refresh().then((_) {
if (id != null && mounted) _scrollToFocused(id); if (id != null && mounted) _scrollToFocused(id);
})); }),
);
}); });
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh()); _schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
// The first load can fire before the project's workspace is wired into // The first load can fire before the project's workspace is wired into
@@ -208,7 +204,9 @@ class _TicketsViewState extends State<TicketsView> {
children: [ children: [
Row( Row(
children: [ 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(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ClideTappable( child: ClideTappable(
@@ -277,14 +275,7 @@ class _TicketsViewState extends State<TicketsView> {
/// type; double-click isolates it (chart-legend solo). One [GestureDetector] /// type; double-click isolates it (chart-legend solo). One [GestureDetector]
/// owns both so Flutter disambiguates single vs double. /// owns both so Flutter disambiguates single vs double.
class _TypeChip extends StatelessWidget { class _TypeChip extends StatelessWidget {
const _TypeChip({ const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
required this.label,
required this.color,
required this.active,
required this.onToggle,
required this.onSolo,
required this.tokens,
});
final String label; final String label;
final Color color; final Color color;
@@ -317,7 +308,11 @@ class _TypeChip extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 6),
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted), ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
], ],
@@ -412,15 +407,17 @@ class _TicketCard extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ClideText(entry.title, fontSize: clideFontCaption), ClideText(entry.title, fontSize: clideFontCaption),
if (statusLabel != null) ...[ if (statusLabel != null) ...[const SizedBox(height: 6), _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status)],
const SizedBox(height: 6),
_StatusBadge(label: statusLabel, tokens: tokens, status: entry.status),
],
], ],
), ),
// Hover affordance (T-327): hand the full ticket to the focused // Hover affordance (T-327): hand the full ticket to the focused
// Claude pane via the message bus. // 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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
color: color.withAlpha(0x30),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily), 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; final tokens = ClideTheme.of(context).surface;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClideText( child: ClideText('-- ${service.mode.label} --', fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.statusBarForeground),
'-- ${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', id: 'workspace.open-project',
command: 'workspace.open-project', command: 'workspace.open-project',
title: 'Workspace: Open project…', title: 'Workspace: Open project…',
run: (_) async => IpcResponse.ok( run: (_) async => IpcResponse.ok(id: '', data: const {'note': 'project picker lands in a later tier'}),
id: '',
data: const {'note': 'project picker lands in a later tier'},
),
), ),
]; ];
} }
+53 -72
View File
@@ -40,15 +40,16 @@ class WelcomeView extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)), Expanded(
child: _StartColumn(tokens: tokens, kernel: kernel),
),
const SizedBox(width: 56), const SizedBox(width: 56),
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)), Expanded(
child: _RecentColumn(tokens: tokens, kernel: kernel),
),
], ],
), ),
if (showTips) ...[ if (showTips) ...[const SizedBox(height: 48), _TipsCard(tokens: tokens)],
const SizedBox(height: 48),
_TipsCard(tokens: tokens),
],
], ],
), ),
), ),
@@ -119,7 +120,9 @@ class _TipsCard extends StatelessWidget {
Expanded( Expanded(
child: Row( child: Row(
children: [ 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), ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily),
], ],
), ),
@@ -166,27 +169,9 @@ class _StartColumn extends StatelessWidget {
children: [ children: [
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 20), const SizedBox(height: 20),
_ActionRow( _ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
icon: PhosphorIcons.byName('folder'), _ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
label: 'Open folder…', _ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
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) { if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary'); kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else { } else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog( kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
path: picked,
onDismiss: () => dismiss(),
));
} }
} }
return; return;
@@ -239,15 +221,14 @@ class _ActionRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: Row( child: Row(
children: [ children: [
ClideIcon(icon, size: 18, color: tokens.globalTextMuted), ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
const SizedBox(width: 14), 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), if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
], ],
), ),
@@ -296,12 +277,7 @@ class _RecentColumn extends StatelessWidget {
} }
class _RecentRow extends StatelessWidget { class _RecentRow extends StatelessWidget {
const _RecentRow({ const _RecentRow({required this.project, required this.tokens, required this.onTap, required this.onToggleSticky});
required this.project,
required this.tokens,
required this.onTap,
required this.onToggleSticky,
});
final RecentProject project; final RecentProject project;
final SurfaceTokens tokens; final SurfaceTokens tokens;
final VoidCallback onTap; final VoidCallback onTap;
@@ -313,10 +289,7 @@ class _RecentRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -328,27 +301,36 @@ class _RecentRow extends StatelessWidget {
Row( Row(
children: [ children: [
Flexible( Flexible(
child: ClideText(project.relativePath, child: ClideText(
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)), project.relativePath,
muted: true,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (project.branch != null) ...[ if (project.branch != null) ...[
ClideText(' · ', muted: true, fontSize: clideFontMeta), ClideText(' · ', muted: true, fontSize: clideFontMeta),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted), ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted),
const SizedBox(width: 3), const SizedBox(width: 3),
Flexible( Flexible(
child: ClideText(project.branch!, child: ClideText(
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)), project.branch!,
muted: true,
fontSize: clideFontMeta,
fontFamily: clideMonoFamily,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
], ],
], ],
), ),
], ],
), ),
), ),
_StickyToggle( _StickyToggle(key: ValueKey('welcome.sticky.${project.path}'), sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
key: ValueKey('welcome.sticky.${project.path}'),
sticky: project.startupSticky,
tokens: tokens,
onTap: onToggleSticky,
),
const SizedBox(width: 12), const SizedBox(width: 12),
ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta), ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta),
], ],
@@ -420,8 +402,12 @@ class _StatusLine extends StatelessWidget {
else if (tc.allOk) else if (tc.allOk)
ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess) ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
else else
ClideText(tc.missing.map((t) => '$t not found').join(' · '), ClideText(
fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusWarning), tc.missing.map((t) => '$t not found').join(' · '),
fontSize: clideFontSmall,
fontFamily: clideMonoFamily,
color: tokens.statusWarning,
),
ClideText(' · ', muted: true, fontSize: clideFontSmall), ClideText(' · ', muted: true, fontSize: clideFontSmall),
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName), _ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
], ],
@@ -526,16 +512,17 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
controller: _controller, controller: _controller,
focusNode: _focus, focusNode: _focus,
style: TextStyle( style: TextStyle(
color: tokens.globalForeground, fontSize: clideFontCaption, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback), color: tokens.globalForeground,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
),
cursorColor: tokens.globalForeground, cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted, backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: (_) => unawaited(_submit()), onSubmitted: (_) => unawaited(_submit()),
), ),
), ),
if (_error != null) ...[ if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall),
],
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@@ -575,17 +562,11 @@ class _NotARepoDialog extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: clideFontMeta), ClideText(path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8), const SizedBox(height: 8),
const ClideText( const ClideText('A clide project root requires a git repository.', muted: true, fontSize: clideFontMeta),
'A clide project root requires a git repository.',
muted: true,
fontSize: clideFontMeta,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
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 /// A status-bar item. Order is determined by [priority] within each
/// alignment group; negative priorities float left, positive right. /// alignment group; negative priorities float left, positive right.
class StatusItemContribution extends ContributionPoint { class StatusItemContribution extends ContributionPoint {
const StatusItemContribution({ const StatusItemContribution({required super.id, required this.build, this.priority = 0, this.listenable, this.flex = 0});
required super.id,
required this.build,
this.priority = 0,
this.listenable,
this.flex = 0,
});
@override @override
SlotId get slot => Slots.statusbar; SlotId get slot => Slots.statusbar;
@@ -80,14 +74,7 @@ class StatusItemContribution extends ContributionPoint {
/// A button in the main toolbar. /// A button in the main toolbar.
class ToolbarButtonContribution extends ContributionPoint { class ToolbarButtonContribution extends ContributionPoint {
const ToolbarButtonContribution({ const ToolbarButtonContribution({required super.id, required this.label, required this.onPressed, this.icon, this.tooltip, this.priority = 0});
required super.id,
required this.label,
required this.onPressed,
this.icon,
this.tooltip,
this.priority = 0,
});
@override @override
SlotId get slot => Slots.toolbar; SlotId get slot => Slots.toolbar;
@@ -101,14 +88,7 @@ class ToolbarButtonContribution extends ContributionPoint {
/// A command extensions register with [CommandRegistry]. Surfaced by the /// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands. /// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint { class CommandContribution extends ContributionPoint {
const CommandContribution({ const CommandContribution({required super.id, required this.command, required this.run, this.title, this.defaultBinding, this.bindingWhen});
required super.id,
required this.command,
required this.run,
this.title,
this.defaultBinding,
this.bindingWhen,
});
final String command; // e.g. "git.commit" final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged" final String? title; // "Git: Commit staged"
@@ -124,12 +104,7 @@ class CommandContribution extends ContributionPoint {
/// Registers an item in the OS tray / menu-bar. /// Registers an item in the OS tray / menu-bar.
class TrayItemContribution extends ContributionPoint { class TrayItemContribution extends ContributionPoint {
const TrayItemContribution({ const TrayItemContribution({required super.id, required this.label, required this.onSelected, this.priority = 0});
required super.id,
required this.label,
required this.onSelected,
this.priority = 0,
});
@override @override
SlotId get slot => Slots.tray; SlotId get slot => Slots.tray;
@@ -141,11 +116,7 @@ class TrayItemContribution extends ContributionPoint {
/// A named layout arrangement. One "classic" preset ships with /// A named layout arrangement. One "classic" preset ships with
/// `builtin.default-layout`; other presets can be contributed. /// `builtin.default-layout`; other presets can be contributed.
class LayoutPresetContribution extends ContributionPoint { class LayoutPresetContribution extends ContributionPoint {
const LayoutPresetContribution({ const LayoutPresetContribution({required super.id, required this.displayName, required this.slots});
required super.id,
required this.displayName,
required this.slots,
});
final String displayName; final String displayName;
final List<LayoutSlot> slots; final List<LayoutSlot> slots;
@@ -154,14 +125,7 @@ class LayoutPresetContribution extends ContributionPoint {
/// One slot in a [LayoutPresetContribution]. Describes where the slot /// One slot in a [LayoutPresetContribution]. Describes where the slot
/// appears and its initial size/visibility. /// appears and its initial size/visibility.
class LayoutSlot { class LayoutSlot {
const LayoutSlot({ const LayoutSlot({required this.slot, required this.position, this.defaultSize, this.minSize, this.maxSize, this.visible = true});
required this.slot,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
this.visible = true,
});
final SlotId slot; final SlotId slot;
final SlotPosition position; 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); String t(String key, {String? placeholder}) => i18n.string(key, namespace: id, placeholder: placeholder);
/// [t] with interpolation replacers. /// [t] with interpolation replacers.
String tr( String tr(String key, {String? placeholder, List<I18nReplacer> replacers = const []}) =>
String key, { i18n.interpolated(key, namespace: id, placeholder: placeholder, replacers: replacers);
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); if (d is String) deps.add(d);
} }
} }
return ExtensionManifest( return ExtensionManifest(id: id, title: title, version: version, dependsOn: deps, entry: entry, schemaVersion: schemaVersion);
id: id,
title: title,
version: version,
dependsOn: deps,
entry: entry,
schemaVersion: schemaVersion,
);
} }
static Future<ExtensionManifest> fromFile(File f) async => ExtensionManifest.fromYamlString(await f.readAsString()); 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]. /// Result of [CliInstaller.install].
class CliInstallResult { class CliInstallResult {
const CliInstallResult({ const CliInstallResult({required this.ok, required this.message, this.installedPath, this.onPath = true, this.fromDevTree = false});
required this.ok,
required this.message,
this.installedPath,
this.onPath = true,
this.fromDevTree = false,
});
final bool ok; final bool ok;
final String message; final String message;
@@ -80,12 +74,8 @@ class CliInstallResult {
/// environment, candidate client locations, the target dir) is injectable so /// environment, candidate client locations, the target dir) is injectable so
/// the logic is unit-testable without a real install. /// the logic is unit-testable without a real install.
class CliInstaller { class CliInstaller {
CliInstaller({ CliInstaller({required this.resolvedExecutable, Map<String, String>? env, List<String>? bundledClientCandidates, String? installDir})
required this.resolvedExecutable, : env = env ?? Platform.environment,
Map<String, String>? env,
List<String>? bundledClientCandidates,
String? installDir,
}) : env = env ?? Platform.environment,
bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment), bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment),
installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment); installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment);
@@ -132,7 +122,8 @@ class CliInstaller {
if (src == null) { if (src == null) {
return const CliInstallResult( return const CliInstallResult(
ok: false, 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.', '`make build` so the C client ships inside the app bundle.',
); );
} }
@@ -203,11 +194,7 @@ class CliInstaller {
return null; return null;
} }
String _expandedPath() => expandedPath( String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
env['PATH'] ?? '',
macOS: Platform.isMacOS,
home: env['HOME'] ?? '',
);
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin'; static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
@@ -217,10 +204,7 @@ class CliInstaller {
/// `Contents/MacOS/` on macOS). /// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) { static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path; final exeDir = File(resolvedExecutable).parent.path;
return [ return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli'];
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. /// platform-parameterized function so both branches are testable off-platform.
String expandedPath(String base, {required bool macOS, String home = ''}) { String expandedPath(String base, {required bool macOS, String home = ''}) {
if (!macOS) return base; if (!macOS) return base;
final extras = <String>[ final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
if (home.isNotEmpty) '$home/.local/bin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/bin',
];
final existing = base.split(':').toSet(); final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p)); final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base; if (missing.isEmpty) return base;
+1 -4
View File
@@ -14,10 +14,7 @@ class ClideClipboard {
final int historyLimit; final int historyLimit;
final Map<Type, List<Object>> _history = {}; final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>( Future<void> write<T extends Object>(T value, {String Function(T)? toPlain}) async {
T value, {
String Function(T)? toPlain,
}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]); final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value); bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast(); 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. /// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable @immutable
class Keybinding { class Keybinding {
Keybinding({required Set<String> modifiers, required String key}) Keybinding({required Set<String> modifiers, required String key}) : modifiers = _canonModifiers(modifiers), key = key.toLowerCase();
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
final List<String> modifiers; final List<String> modifiers;
final String key; final String key;
+2 -9
View File
@@ -17,19 +17,12 @@ class CommandRegistry extends ChangeNotifier {
Iterable<CommandContribution> get all => _byCommand.values; Iterable<CommandContribution> get all => _byCommand.values;
CommandContribution? get(String command) => _byCommand[command]; CommandContribution? get(String command) => _byCommand[command];
Future<IpcResponse> execute( Future<IpcResponse> execute(String command, {List<String> args = const []}) async {
String command, {
List<String> args = const [],
}) async {
final c = _byCommand[command]; final c = _byCommand[command];
if (c == null) { if (c == null) {
return IpcResponse.err( return IpcResponse.err(
id: '', id: '',
error: IpcError( error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such command: $command'),
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such command: $command',
),
); );
} }
return c.run(args); return c.run(args);
+3 -14
View File
@@ -2,10 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function( typedef DialogBuilder<T> = Widget Function(BuildContext context, void Function([T? result]) dismiss);
BuildContext context,
void Function([T? result]) dismiss,
);
/// Single-at-a-time modal router. /// Single-at-a-time modal router.
/// ///
@@ -70,12 +67,7 @@ class _Queued {
/// Hosts the current dialog from [DialogRouter]. Place high in the tree /// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface. /// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget { class DialogHost extends StatelessWidget {
const DialogHost({ const DialogHost({super.key, required this.router, required this.child, this.backdropColor = const Color(0xC0000000)});
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
final DialogRouter router; final DialogRouter router;
final Widget child; final Widget child;
@@ -98,10 +90,7 @@ class DialogHost extends StatelessWidget {
child: ColoredBox( child: ColoredBox(
color: backdropColor, color: backdropColor,
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(onTap: () {}, child: b(ctx, router.dismiss)),
onTap: () {},
child: b(ctx, router.dismiss),
),
), ),
), ),
), ),
+1 -5
View File
@@ -1,11 +1,7 @@
import 'dart:async'; import 'dart:async';
class Message { class Message {
Message({ Message({required this.publisher, required this.channel, required this.data}) : timestamp = DateTime.now();
required this.publisher,
required this.channel,
required this.data,
}) : timestamp = DateTime.now();
final String publisher; final String publisher;
final String channel; final String channel;
+3 -18
View File
@@ -13,13 +13,7 @@ class ClideEventEnvelope {
final ClideEvent event; final ClideEvent event;
final DateTime timestamp; final DateTime timestamp;
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {'v': 1, 'subsystem': event.subsystem, 'kind': event.kind, 'ts': timestamp.toIso8601String(), 'data': event.payload()};
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
} }
class DaemonConnectionChanged extends ClideEvent { class DaemonConnectionChanged extends ClideEvent {
@@ -89,12 +83,7 @@ class ExtensionDeactivated extends ClideEvent {
/// narrow by subsystem+kind, or register a converter that emits a typed /// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus. /// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent { class DaemonEvent extends ClideEvent {
const DaemonEvent({ const DaemonEvent({required this.subsystem, required this.kind, required this.data, required this.ts});
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
@override @override
final String subsystem; 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 /// A Claude Code tmux teammate's pane went away (it exited or the team
/// dissolved) — T-139. /// dissolved) — T-139.
class TeamMemberLeft extends ClideEvent { class TeamMemberLeft extends ClideEvent {
const TeamMemberLeft({ const TeamMemberLeft({required this.team, required this.agentId, required this.paneId});
required this.team,
required this.agentId,
required this.paneId,
});
final String team; final String team;
final String agentId; final String agentId;
+4 -13
View File
@@ -147,13 +147,7 @@ class KernelServices {
final settings = SettingsStore(appDir: appDir); final settings = SettingsStore(appDir: appDir);
await settings.load(); await settings.load();
final i18n = I18n( final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
loader: i18nLoader,
log: log,
defaultLocale: defaultLocale,
initialLocale: initialLocale,
availableLocales: availableLocales,
);
for (final ns in preloadNamespaces) { for (final ns in preloadNamespaces) {
await i18n.ensureNamespaceLoaded(ns); await i18n.ensureNamespaceLoaded(ns);
} }
@@ -193,7 +187,8 @@ class KernelServices {
onProjectOpen: onProjectOpen, onProjectOpen: onProjectOpen,
onValidateProject: onValidateProject, onValidateProject: onValidateProject,
); );
final ipc = isolateClient ?? final ipc =
isolateClient ??
(daemonClientFactory != null (daemonClientFactory != null
? daemonClientFactory(log, events, arrangement, panels) ? daemonClientFactory(log, events, arrangement, panels)
: DaemonClient( : DaemonClient(
@@ -309,11 +304,7 @@ class KernelServices {
} }
class ClideKernel extends InheritedWidget { class ClideKernel extends InheritedWidget {
const ClideKernel({ const ClideKernel({super.key, required this.services, required super.child});
super.key,
required this.services,
required super.child,
});
final KernelServices services; final KernelServices services;

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