13 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
jpmschweitzerandClaude Opus 4.8 138872e990 release v2.3.1
Patch release: ticket/decision sidebars load on first open (T-352), plus
the KWin frameless-chrome map fix (T-351) and the transient pql-failure
retry (T-350) that landed since 2.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:40:09 +02:00
jpmschweitzerandClaude Opus 4.8 a0501b4a0a pql: refetch ticket + decision sidebars when the workspace opens (T-352)
On a desktop launch the daemon's PqlClient boots with workDir set to the
launch CWD (e.g. HOME), not the repo — swapIpcServer only rewires it once
the project opens. The tickets and decisions panes fire their first pql
fetch before that swap, so pql runs in the wrong directory against a
stale/global pql.db and the pane errors (observed:
"ticket_deps.blocker_record_id missing — pql.db is from an earlier
schema"). A manual refresh worked because by then the workspace was open.

This is a wrong-workDir timing issue, not db-busy, so the T-350 retry
doesn't catch it. Both panes now re-fetch on ProjectOpened, which fires
after the IPC server swaps to the project workRoot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:44 +02:00
jpmschweitzerandClaude Opus 4.8 7cea13c5c0 linux: request no-decorations on map, not just realize (T-351)
On KDE Plasma 6 / KWin 6 the frameless chrome still showed the native
title bar even with the decoration code compiled in. The KDE
server-decoration request ran on the GtkWidget "realize" signal, but
GTK's Wayland backend only creates the wl_surface on map — so at realize
gdk_wayland_window_get_wl_surface() was null and the request bailed,
leaving KWin (which defaults to server-side decorations on Wayland) to
draw its title bar.

Also connect the handler to "map", where the surface is live. The realize
pass still does the X11 gdk_window_set_decorations hint and bails harmlessly
on the Wayland part, so no duplicate decoration object is created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:24:50 +02:00
jpmschweitzerandClaude Opus 4.8 9a8175903b pql: retry transient db-busy so sidebar panes don't stick (T-350)
The pql-backed sidebar panes fetch once on first build. If that fetch
fired too early — the planning DB still settling at startup, or a db-busy
SQLite lock under concurrent pql writes (pql exits 69) — the pane showed
"pql … failed" and stayed there until a manual refresh re-fired it.

Retry transient failures at the single chokepoint, PqlClient._run: on a
busy/locked signal (exit 69, or stderr mentioning database is locked /
busy) retry a few times with short backoff before throwing. Genuine
errors aren't busy, so they still surface immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:20:36 +02:00
452 changed files with 8006 additions and 12882 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.44.1"
}
+15
View File
@@ -3510,3 +3510,18 @@ 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 ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'status', 'backlog', 'done', NULL, '2026-06-10 18:20:28', '2026-06-10 18:20:28', '2026-06-10 18:20:28', NULL, '213109fcd67b4375ebbd3b59c4a1ed04', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'description', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 2) ON CONFLICT(hash) DO NOTHING;
+4
View File
@@ -174,3 +174,7 @@ 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 ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'T-347', '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '9d40226dbc6136072d2d5d0eda71f141', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'T-347', '2026-06-10 16:55:10', '2026-06-10 16:55:10', NULL, '9d40226dbc6136072d2d5d0eda71f141', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'T-348', '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, '8bb5ad92551f272417840869a0774668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'T-348', '2026-06-10 17:10:42', '2026-06-10 17:10:42', NULL, '8bb5ad92551f272417840869a0774668', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'T-349', '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, '0b12f14fa83254ab113c870531628359', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'T-349', '2026-06-10 17:45:28', '2026-06-10 17:45:28', NULL, '0b12f14fa83254ab113c870531628359', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+55
View File
@@ -3213,3 +3213,58 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE. So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
Fix: make wayland-client a hard build requirement fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 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); Fix: make wayland-client a hard build requirement fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'd03c864f5e64284f3582e59556a4ab4f', 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 ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:20:28', NULL, 'd48c86ad83f3da7cd4f8c9308c852448', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, 'ec216013c0bb79f1f11a87979c9fb886', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'review', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:24:41', NULL, '0d06f87ff4d71e2ee4e4821968b5e3df', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'bug', NULL, 'Frameless chrome: KWin keeps the title bar — decoration request fires before the wl_surface exists', 'On KDE Plasma 6 / KWin 6 (Wayland), clide still shows the compositor''s native title bar even though the frameless code (D-057, KDE server-decoration protocol) is compiled in (confirmed: with T-349 making wayland-client REQUIRED, a 2.3.0 build that runs at all has it).
Root cause is timing, not the protocol. linux/runner/clide_app.cc connects request_no_server_decorations() to the GtkWidget ''realize'' signal, but GTK''s Wayland backend only creates the wl_surface on MAP, not realize. So gdk_wayland_window_get_wl_surface() returns null and the function bails at its own ''if (surface == nullptr) return'' before creating the org_kde_kwin_server_decoration / requesting mode NONE. KWin 6 defaults to server-side decorations on Wayland unless that request lands native title bar shows (double title bar with clide''s own chrome).
Fix: also fire the request on the ''map'' signal (wl_surface is live by then); the realize handler still does the X11 gdk_window_set_decorations hint and harmlessly bails on the Wayland part (surface null) so no duplicate decoration object is created. If KWin 6 turns out not to honor the legacy KDE protocol, fall back to the standard xdg-decoration protocol (zxdg_decoration_manager_v1, set_mode CLIENT_SIDE).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:23:41', '2026-06-10 18:27:58', NULL, 'e61eeca3f6ccf42a334867c757fda759', 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 ('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.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, 'a6d1821c06dbccb30c9f7e777aef7ef5', 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 ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
FOLLOW-UP SCOPE (folded in 2026-06-11):
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
2. TODO tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
3. TODO exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+72
View File
@@ -16,6 +16,78 @@ 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
### Fixed
- **Frameless window chrome works on KDE Plasma 6 / KWin 6.** The Wayland
server-decoration request fired on `realize`, before GTK created the
surface, so it bailed and KWin (which defaults to server-side decorations)
kept drawing its own title bar. It now also fires on `map`. (T-351)
- **pql sidebar panes no longer stick on a transient startup error.** A
too-early or db-busy pql failure (the planning DB still settling, or a
SQLite lock under concurrent writes) is now retried a few times before
surfacing, instead of leaving the pane on "pql … failed" until a manual
refresh. (T-350)
- **Ticket and decision sidebars load on first open, not just after a manual
refresh.** On a desktop launch the daemon's pql workspace starts as the
launch directory, not the repo, so the panes' first fetch ran against the
wrong (or a stale-schema) DB and errored. They now re-fetch when the
workspace actually opens, by which point the pql workspace is the repo. (T-352)
## [2.3.0] — 2026-06-10 ## [2.3.0] — 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.0" 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
+96 -96
View File
@@ -71,112 +71,112 @@ class ClideTheme {
// ─── ThemeData ──────────────────────────────────────────────────────── // ─── ThemeData ────────────────────────────────────────────────────────
static ThemeData get data => ThemeData( static ThemeData get data => ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: _bg, scaffoldBackgroundColor: _bg,
canvasColor: _bg, canvasColor: _bg,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
primary: _accent, primary: _accent,
onPrimary: Color(0xFF0D1020), onPrimary: Color(0xFF0D1020),
secondary: _synKeyword, secondary: _synKeyword,
onSecondary: Color(0xFF0D1020), onSecondary: Color(0xFF0D1020),
surface: _surface, surface: _surface,
onSurface: _textHi, onSurface: _textHi,
surfaceContainerHighest: _surfaceHi, surfaceContainerHighest: _surfaceHi,
outline: _border, outline: _border,
outlineVariant: _borderHi, outlineVariant: _borderHi,
error: _err, error: _err,
onError: Color(0xFF0D1020), onError: Color(0xFF0D1020),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
// Josefin Sans Light for display; JetBrains Mono for code/body. // Josefin Sans Light for display; JetBrains Mono for code/body.
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi), displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi), displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi), headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text), titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute), labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi), bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text), bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
), ),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1), dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: _surface, color: _surface,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
side: const BorderSide(color: _border), side: const BorderSide(color: _border),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: _bgSunken, fillColor: _bgSunken,
hintStyle: const TextStyle(color: _textMute), hintStyle: const TextStyle(color: _textMute),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border), borderSide: const BorderSide(color: _border),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _border), borderSide: const BorderSide(color: _border),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: _accent, width: 1.5), borderSide: const BorderSide(color: _accent, width: 1.5),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _accent, backgroundColor: _accent,
foregroundColor: const Color(0xFF0D1020), foregroundColor: const Color(0xFF0D1020),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500), textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: _accent, foregroundColor: _accent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12), textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
), ),
), ),
// Pill-style chips (matches the Projects row in the dashboard). // Pill-style chips (matches the Projects row in the dashboard).
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: _surface, backgroundColor: _surface,
side: const BorderSide(color: _borderHi), side: const BorderSide(color: _borderHi),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text), labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
), ),
iconTheme: const IconThemeData(color: _textDim, size: 14), iconTheme: const IconThemeData(color: _textDim, size: 14),
tooltipTheme: TooltipThemeData( tooltipTheme: TooltipThemeData(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _surfaceHi, color: _surfaceHi,
border: Border.all(color: _borderHi), border: Border.all(color: _borderHi),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi), textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
), ),
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStatePropertyAll(_border), thumbColor: WidgetStatePropertyAll(_border),
thickness: const WidgetStatePropertyAll(6), thickness: const WidgetStatePropertyAll(6),
radius: const Radius.circular(3), radius: const Radius.circular(3),
), ),
); );
} }
/// Raw color tokens — use when Material widgets can't carry the meaning. /// Raw color tokens — use when Material widgets can't carry the meaning.
+29 -29
View File
@@ -63,33 +63,33 @@ class MidnightTheme {
); );
static ThemeData get data => ThemeData( static ThemeData get data => ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: _bg, scaffoldBackgroundColor: _bg,
canvasColor: _bg, canvasColor: _bg,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: _accent, primary: _accent,
onPrimary: Color(0xFF0B1220), onPrimary: Color(0xFF0B1220),
secondary: _synKeyword, secondary: _synKeyword,
onSecondary: Color(0xFF0B1220), onSecondary: Color(0xFF0B1220),
surface: _surface, surface: _surface,
onSurface: _textHi, onSurface: _textHi,
surfaceContainerHighest: _surfaceHi, surfaceContainerHighest: _surfaceHi,
outline: _border, outline: _border,
outlineVariant: _borderHi, outlineVariant: _borderHi,
error: _err, error: _err,
onError: Color(0xFF0B1220), onError: Color(0xFF0B1220),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi), displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi), displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi), headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text), titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute), labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi), bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text), bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
), ),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1), dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14), iconTheme: const IconThemeData(color: _textDim, size: 14),
); );
} }
+29 -29
View File
@@ -65,33 +65,33 @@ class PaperTheme {
); );
static ThemeData get data => ThemeData( static ThemeData get data => ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.light, brightness: Brightness.light,
scaffoldBackgroundColor: _bg, scaffoldBackgroundColor: _bg,
canvasColor: _bg, canvasColor: _bg,
colorScheme: const ColorScheme.light( colorScheme: const ColorScheme.light(
primary: _accent, primary: _accent,
onPrimary: Color(0xFFFBF8F1), onPrimary: Color(0xFFFBF8F1),
secondary: _info, secondary: _info,
onSecondary: Color(0xFFFBF8F1), onSecondary: Color(0xFFFBF8F1),
surface: _surface, surface: _surface,
onSurface: _textHi, onSurface: _textHi,
surfaceContainerHighest: _surfaceHi, surfaceContainerHighest: _surfaceHi,
outline: _border, outline: _border,
outlineVariant: _borderHi, outlineVariant: _borderHi,
error: _err, error: _err,
onError: Color(0xFFFBF8F1), onError: Color(0xFFFBF8F1),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi), displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi), displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi), headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text), titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim), labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi), bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text), bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
), ),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1), dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _text, size: 14), iconTheme: const IconThemeData(color: _text, size: 14),
); );
} }
+30 -30
View File
@@ -65,34 +65,34 @@ class TerminalTheme {
); );
static ThemeData get data => ThemeData( static ThemeData get data => ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: _bg, scaffoldBackgroundColor: _bg,
canvasColor: _bg, canvasColor: _bg,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: _accent, primary: _accent,
onPrimary: Color(0xFF000000), onPrimary: Color(0xFF000000),
secondary: _info, secondary: _info,
onSecondary: Color(0xFF000000), onSecondary: Color(0xFF000000),
surface: _surface, surface: _surface,
onSurface: _textHi, onSurface: _textHi,
surfaceContainerHighest: _surfaceHi, surfaceContainerHighest: _surfaceHi,
outline: _border, outline: _border,
outlineVariant: _borderHi, outlineVariant: _borderHi,
error: _err, error: _err,
onError: Color(0xFF000000), onError: Color(0xFF000000),
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi), displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi), displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi), headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text), titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
// Terminal mockups go full-mono even for display. // Terminal mockups go full-mono even for display.
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute), labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi), bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text), bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
), ),
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1), dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
iconTheme: const IconThemeData(color: _textDim, size: 14), iconTheme: const IconThemeData(color: _textDim, size: 14),
); );
} }
+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
+62 -116
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(
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens), children: [
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens), _WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true), _WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
]); _WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
],
);
} }
} }
@@ -417,11 +410,7 @@ class _WinBtn extends StatelessWidget {
height: hatHeight, 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(
+27 -62
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;
@@ -109,12 +103,12 @@ class ClaudeProbe {
final String? permissionMode; final String? permissionMode;
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'version': version, 'version': version,
'slash_commands': slashCommands, 'slash_commands': slashCommands,
'skills': skills, 'skills': skills,
if (model != null) 'model': model, if (model != null) 'model': model,
if (permissionMode != null) 'permission_mode': permissionMode, if (permissionMode != null) 'permission_mode': permissionMode,
}; };
/// Build from a stream-json `init` event object. Returns null if it doesn't /// Build from a stream-json `init` event object. Returns null if it doesn't
/// look like an init event (no version field). /// look like an init event (no version field).
@@ -130,12 +124,12 @@ class ClaudeProbe {
} }
static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe( static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe(
version: (j['version'] as String?) ?? '', version: (j['version'] as String?) ?? '',
slashCommands: _stringList(j['slash_commands']), slashCommands: _stringList(j['slash_commands']),
skills: _stringList(j['skills']), skills: _stringList(j['skills']),
model: j['model'] as String?, model: j['model'] as String?,
permissionMode: j['permission_mode'] as String?, permissionMode: j['permission_mode'] as String?,
); );
} }
List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const []; List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const [];
@@ -184,13 +178,13 @@ class ClaudeConfig extends ChangeNotifier {
ClaudeInitProbe? initProbe, ClaudeInitProbe? initProbe,
ClaudeConfigWatch? watch, ClaudeConfigWatch? watch,
Duration debounce = const Duration(milliseconds: 150), Duration debounce = const Duration(milliseconds: 150),
}) : _globalDir = globalDir, }) : _globalDir = globalDir,
_cacheDir = cacheDir, _cacheDir = cacheDir,
_projectDir = projectDir, _projectDir = projectDir,
_versionRunner = versionRunner ?? _defaultVersionRunner, _versionRunner = versionRunner ?? _defaultVersionRunner,
_initProbe = initProbe ?? _defaultInitProbe, _initProbe = initProbe ?? _defaultInitProbe,
_watch = watch, _watch = watch,
_debounceFor = debounce; _debounceFor = debounce;
final Directory _globalDir; final Directory _globalDir;
final Directory _cacheDir; final Directory _cacheDir;
@@ -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;
+96 -173
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(
id: forkId, orch.spawn(
role: 'fork of $memberName', SpawnSpec(
// sessionId is a placeholder; real claude session id arrives via init. id: forkId,
sessionId: forkId, role: 'fork of $memberName',
cwd: managed.cwd, // sessionId is a placeholder; real claude session id arrives via init.
forkSourceSessionId: managed.sessionId, sessionId: forkId,
))); cwd: managed.cwd,
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.
@@ -454,22 +440,22 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
} }
String _configSectionLabel(_ConfigSection section) => switch (section) { String _configSectionLabel(_ConfigSection section) => switch (section) {
_ConfigSection.skills => 'SKILLS', _ConfigSection.skills => 'SKILLS',
_ConfigSection.agents => 'AGENTS', _ConfigSection.agents => 'AGENTS',
_ConfigSection.commands => 'COMMANDS', _ConfigSection.commands => 'COMMANDS',
_ConfigSection.hooks => 'HOOKS', _ConfigSection.hooks => 'HOOKS',
_ConfigSection.permissions => 'PERMISSIONS', _ConfigSection.permissions => 'PERMISSIONS',
_ConfigSection.mcpServers => 'MCP SERVERS', _ConfigSection.mcpServers => 'MCP SERVERS',
}; };
int _configSectionCount(ClaudeConfig config, _ConfigSection section) => switch (section) { int _configSectionCount(ClaudeConfig config, _ConfigSection section) => switch (section) {
_ConfigSection.skills => config.skills.length, _ConfigSection.skills => config.skills.length,
_ConfigSection.agents => config.agents.length, _ConfigSection.agents => config.agents.length,
_ConfigSection.commands => config.commands.length, _ConfigSection.commands => config.commands.length,
_ConfigSection.hooks => config.hooks.length, _ConfigSection.hooks => config.hooks.length,
_ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length, _ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
_ConfigSection.mcpServers => config.mcpServers.length, _ConfigSection.mcpServers => config.mcpServers.length,
}; };
Widget _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) { Widget _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
final expanded = _expanded.contains(section); final expanded = _expanded.contains(section);
@@ -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,
),
), ),
), ),
); );
@@ -576,22 +548,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// allow → statusSuccess, ask → statusWarning, deny → statusError // allow → statusSuccess, ask → statusWarning, deny → statusError
Color kindColor(_ConfigPermKind k) => switch (k) { Color kindColor(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => tokens.statusSuccess, _ConfigPermKind.allow => tokens.statusSuccess,
_ConfigPermKind.ask => tokens.statusWarning, _ConfigPermKind.ask => tokens.statusWarning,
_ConfigPermKind.deny => tokens.statusError, _ConfigPermKind.deny => tokens.statusError,
}; };
String kindLabel(_ConfigPermKind k) => switch (k) { String kindLabel(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => kindAllow, _ConfigPermKind.allow => kindAllow,
_ConfigPermKind.ask => kindAsk, _ConfigPermKind.ask => kindAsk,
_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) {
@@ -631,44 +599,41 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
// --- Shared rendering ----------------------------------------------------- // --- Shared rendering -----------------------------------------------------
Widget _placeholder(String text) => Padding( Widget _placeholder(String text) => Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall), child: ClideText(text, muted: true, fontSize: clideFontSmall),
); );
Widget _metaTable(SurfaceTokens tokens, List<_MetaSection> sections) { Widget _metaTable(SurfaceTokens tokens, List<_MetaSection> sections) {
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: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6), Padding(
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted), padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
)); child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
);
for (final r in s.rows) { for (final r in s.rows) {
children.add(Padding( children.add(
padding: const EdgeInsets.symmetric(vertical: _rowPitch), Padding(
child: Row( padding: const EdgeInsets.symmetric(vertical: _rowPitch),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
SizedBox( children: [
width: _labelColumnWidth, SizedBox(
child: ClideText(r.label, muted: true, fontSize: clideFontSmall), width: _labelColumnWidth,
), child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
Expanded(
child: ClideText(
r.value,
fontSize: clideFontSmall,
color: r.valueColor ?? tokens.globalForeground,
), ),
), Expanded(
], child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
),
],
),
), ),
)); );
} }
} }
return ListView( return ListView(padding: const EdgeInsets.all(12), children: children);
padding: const EdgeInsets.all(12),
children: children,
);
} }
} }
@@ -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: [
@@ -975,11 +933,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
/// Maps a permission-mode string to a single-letter badge label. /// Maps a permission-mode string to a single-letter badge label.
String _permissionModeBadge(String mode) => switch (mode) { String _permissionModeBadge(String mode) => switch (mode) {
'acceptEdits' => 'A', 'acceptEdits' => 'A',
'plan' => 'P', 'plan' => 'P',
'bypassPermissions' => 'B', 'bypassPermissions' => 'B',
_ => 'D', // default _ => 'D', // default
}; };
/// Clickable permission-mode badge shown in each roster row (T-181). /// Clickable permission-mode badge shown in each roster row (T-181).
/// ///
@@ -990,12 +948,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
/// It is a custom painted label (no Material), consistent with the rendering /// 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),
), ),
), ),
), ),
@@ -1262,8 +1185,8 @@ class _TabStrip extends StatelessWidget {
} }
String _label(SidebarTab t) => switch (t) { String _label(SidebarTab t) => switch (t) {
SidebarTab.activity => 'Activity', SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount', SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config', SidebarTab.config => 'Config',
}; };
} }
+29 -54
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(
sub.cancel(); const Duration(seconds: 10),
}); onTimeout: () {
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(
id: _orchId, SpawnSpec(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', id: _orchId,
sessionId: _sessionId!, role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
cwd: repoRoot, sessionId: _sessionId!,
resume: resume, cwd: repoRoot,
transcriptPath: resume ? transcriptFile : null, resume: resume,
)); 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;
@@ -314,10 +315,10 @@ class _ClaudePaneState extends State<ClaudePane> {
// from the transcript/sidecar). Surfaces the resume path in `make run`. // from the transcript/sidecar). Surfaces the resume path in `make run`.
final seeded = _conversation?.items.length ?? 0; final seeded = _conversation?.items.length ?? 0;
_kernel()?.log.info( _kernel()?.log.info(
'claude', 'claude',
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot' 'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot'
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}', '${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
); );
_statusSub = managed.session.statusStream.listen((s) { _statusSub = managed.session.statusStream.listen((s) {
if (!mounted) return; if (!mounted) return;
setState(() => _status = s); setState(() => _status = s);
@@ -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,
),
); );
} }
} }
+14 -10
View File
@@ -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(
id: 'secondary-$index', MultitabEntry<_Session>(
title: 'session $index', id: 'secondary-$index',
payload: _Session(isPrimary: false, secondaryIndex: index), title: 'session $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(
id: 'secondary-$index', MultitabEntry<_Session>(
title: 'fork $index', id: 'secondary-$index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId), title: 'fork $index',
)); payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
),
);
} }
@override @override
+9 -12
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(
date: '${e['date']}', DailyActivity(
messageCount: _int(e['messageCount']), date: '${e['date']}',
sessionCount: _int(e['sessionCount']), messageCount: _int(e['messageCount']),
toolCallCount: _int(e['toolCallCount']), sessionCount: _int(e['sessionCount']),
)); 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.
+22 -24
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)],
),
), ),
], ],
], ],
@@ -76,20 +73,22 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
} }
Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding( Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row( child: Row(
children: [ children: [
ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted), ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted),
const SizedBox(width: 8), const SizedBox(width: 8),
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(
] else child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
const Spacer(), ),
], ] else
), const Spacer(),
); ],
),
);
Widget _taskRow(SurfaceTokens tokens, TaskItem t) { Widget _taskRow(SurfaceTokens tokens, TaskItem t) {
final (String glyph, Color color, String word) = switch (t.status) { final (String glyph, Color color, String word) = switch (t.status) {
@@ -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();
+13 -29
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,
),
), ),
), ),
); );
@@ -330,15 +319,15 @@ class _ConversationCardState extends State<ConversationCard> {
/// A muted sub-label + hairline divider introducing an [CardSegment] below /// A muted sub-label + hairline divider introducing an [CardSegment] below
/// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts. /// the primary body (T-262), so CALL/PROMPT/RESULT read as distinct parts.
Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding( Widget _segmentLabel(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4), padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row( child: Row(
children: [ children: [
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Container(height: 1, color: tokens.panelBorder)), Expanded(child: Container(height: 1, color: tokens.panelBorder)),
], ],
), ),
); );
List<Widget> _actions(SurfaceTokens tokens) { List<Widget> _actions(SurfaceTokens tokens) {
final items = <_ActionItem>[]; final items = <_ActionItem>[];
@@ -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);
} }
+117 -129
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,
@@ -308,39 +306,39 @@ class _ConversationViewState extends State<ConversationView> {
// visible list don't reattach State to the wrong card (T-285). // visible list don't reattach State to the wrong card (T-285).
return switch (g) { return switch (g) {
StickyItem(:final item) => _ConversationTurn( StickyItem(:final item) => _ConversationTurn(
key: ValueKey('turn.${item.uuid}'), key: ValueKey('turn.${item.uuid}'),
item: item, item: item,
tokens: tokens, tokens: tokens,
collapseTools: true, collapseTools: true,
toolUseOutcomes: widget.toolUseOutcomes, toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds, quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById, toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
), ),
FoldedCluster(:final items) => _ActivityCard( FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'), key: ValueKey('cluster.${items.first.uuid}'),
items: items, items: items,
tokens: tokens, tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes, toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds, quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById, toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
), ),
EditRun(:final edits) => _EditRunCard( EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'), key: ValueKey('edits.${edits.first.uuid}'),
edits: edits, edits: edits,
tokens: tokens, tokens: tokens,
toolUseOutcomes: widget.toolUseOutcomes, toolUseOutcomes: widget.toolUseOutcomes,
quietErrorToolUseIds: widget.quietErrorToolUseIds, quietErrorToolUseIds: widget.quietErrorToolUseIds,
toolUseById: widget.controller.toolUseById, toolUseById: widget.controller.toolUseById,
resultByToolUseId: resultByToolUseId, resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId, promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId, runByToolUseId: fold.runByToolUseId,
), ),
}; };
}, },
), ),
@@ -413,7 +411,7 @@ String? resolveWorkspaceFilePath(String? root, String raw) {
/// Open a clicked workspace file reference in the editor, jumping to [line] /// 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.
@@ -474,63 +472,63 @@ class _ConversationTurn extends StatelessWidget {
// sidechain prompt here is an orphan one (its Agent card couldn't be // sidechain prompt here is an orphan one (its Agent card couldn't be
// resolved) — folded prompts are suppressed upstream (T-263). // resolved) — folded prompts are suppressed upstream (T-263).
UserMessage() when i.injected || i.isSidechain => ConversationCard( UserMessage() when i.injected || i.isSidechain => ConversationCard(
// Framed like every other card (T-306) — just muted + collapsed, not // Framed like every other card (T-306) — just muted + collapsed, not
// the blue "you" accent (D-78); bare read as unfinished next to the // the blue "you" accent (D-78); bare read as unfinished next to the
// carded tool calls. // carded tool calls.
variant: ConversationCardVariant.bordered, variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted, accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent prompt' : 'context', label: i.isSidechain ? 'agent prompt' : 'context',
copyText: i.text, copyText: i.text,
collapsible: true, collapsible: true,
collapsedByDefault: true, collapsedByDefault: true,
collapsedSummary: _firstLine(i.text), collapsedSummary: _firstLine(i.text),
margin: _childMargin, margin: _childMargin,
body: ClideText(i.text, muted: true, fontSize: clideFontMeta), body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
), ),
UserMessage() => ConversationCard( UserMessage() => ConversationCard(
accent: tokens.globalFocus, accent: tokens.globalFocus,
label: 'you', label: 'you',
copyText: i.text, copyText: i.text,
margin: _childMargin, margin: _childMargin,
// Pasted-image @path tokens render as inline thumbnails that open the // Pasted-image @path tokens render as inline thumbnails that open the
// lightbox (T-236/T-254); copyText keeps the original text verbatim. // lightbox (T-236/T-254); copyText keeps the original text verbatim.
body: ClideMarkdown( body: ClideMarkdown(
i.text, i.text,
onRecordTap: (id) => _openRecord(context, id), onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48), onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url), onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p), resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line), onOpenFile: (path, line) => _openFile(context, path, line),
),
), ),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the // Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
// agent with a muted accent, never the coral "claude" brand (T-265). The // agent with a muted accent, never the coral "claude" brand (T-265). The
// coral claudeAccent is reserved for the real main-thread Claude. // coral claudeAccent is reserved for the real main-thread Claude.
AssistantTextMessage() => ConversationCard( AssistantTextMessage() => ConversationCard(
accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent, accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent,
label: i.isSidechain ? 'agent' : 'claude', label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text, copyText: i.text,
margin: _childMargin, margin: _childMargin,
body: ClideMarkdown( body: ClideMarkdown(
i.text, i.text,
onRecordTap: (id) => _openRecord(context, id), onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url), onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p), resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line), onOpenFile: (path, line) => _openFile(context, path, line),
),
), ),
),
AssistantThinkingMessage() => ConversationCard( AssistantThinkingMessage() => ConversationCard(
// Framed + muted like the context card (T-306). // Framed + muted like the context card (T-306).
variant: ConversationCardVariant.bordered, variant: ConversationCardVariant.bordered,
accent: tokens.globalTextMuted, accent: tokens.globalTextMuted,
label: i.isSidechain ? 'agent thinking' : 'thinking', label: i.isSidechain ? 'agent thinking' : 'thinking',
copyText: i.thinking, copyText: i.thinking,
collapsible: true, collapsible: true,
collapsedByDefault: true, collapsedByDefault: true,
collapsedSummary: _firstLine(i.thinking), collapsedSummary: _firstLine(i.thinking),
margin: _childMargin, margin: _childMargin,
body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta), body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta),
), ),
AssistantToolUse() => collapseTools ? _toolUseCollapser(i) : _toolContentCard(i), AssistantToolUse() => collapseTools ? _toolUseCollapser(i) : _toolContentCard(i),
ToolResultMessage() => _toolResult(i), ToolResultMessage() => _toolResult(i),
ImageMessage() => _image(context, i), ImageMessage() => _image(context, i),
@@ -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),
],
], ],
), ),
); );
@@ -583,34 +578,30 @@ class _ConversationTurn extends StatelessWidget {
void _openLightbox(BuildContext context, String path) { void _openLightbox(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: (_, _, _) => _imagePlaceholder(path)),
image: ClideFileImage(path), ),
fit: BoxFit.contain, );
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
),
),
);
} }
Widget _imagePlaceholder(String path) => Container( Widget _imagePlaceholder(String path) => Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: tokens.panelBorder), border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
), ),
child: Row( ],
mainAxisSize: MainAxisSize.min, ),
children: [ );
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
),
],
),
);
/// A standalone tool use (T-305): every tool use is a collapser over a /// A standalone tool use (T-305): every tool use is a collapser over a
/// one-item list. The collapser carries the echoed last line, the count, and /// one-item list. The collapser carries the echoed last line, the count, and
@@ -685,8 +676,15 @@ class _ConversationTurn extends StatelessWidget {
// (note E): call input (body) → prompt → returned result. // (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,
),
); );
} }
+263 -274
View File
@@ -48,270 +48,259 @@ class ClaudeExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'claude.primary', id: 'claude.primary',
slot: Slots.workspace, slot: Slots.workspace,
title: 'Claude', title: 'Claude',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: 90, priority: 90,
build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)), build: (_) => TeamPanelHost(lead: ClaudeSessionHost(key: _hostKey)),
), ),
CommandContribution( CommandContribution(
id: 'claude.new-secondary', id: 'claude.new-secondary',
command: 'claude.new-secondary', command: 'claude.new-secondary',
title: 'Claude: open a secondary session', title: 'Claude: open a secondary session',
run: (_) async { run: (_) async {
_hostKey.currentState?.addSecondary(); _hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'}); return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
}, },
), ),
CommandContribution( CommandContribution(
id: 'claude.kill-all-sessions', id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions', command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo', title: 'Claude: kill all sessions for this repo',
run: _killAllSessions, run: _killAllSessions,
), ),
CommandContribution( CommandContribution(
id: 'claude.session-storage', id: 'claude.session-storage',
command: 'claude.session-storage', command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)', title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage, run: _manageStorage,
), ),
// T-235: cycle how aggressively the activity card folds meta steps // T-235: cycle how aggressively the activity card folds meta steps
// (none → tools → thinking → everything), persisted app-wide. The panes // (none → tools → thinking → everything), persisted app-wide. The panes
// read kActivityFoldLevelKey and rebuild via the settings notifier. // read kActivityFoldLevelKey and rebuild via the settings notifier.
CommandContribution( CommandContribution(
id: 'claude.activity.fold-level', id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level', command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level', title: 'Claude: cycle activity fold level',
run: _cycleFoldLevel, run: _cycleFoldLevel,
), ),
// T-171: agent roster controls (D-6 CLI/UI parity). // T-171: agent roster controls (D-6 CLI/UI parity).
// Usage: clide claude.agent.show <sessionId> // Usage: clide claude.agent.show <sessionId>
CommandContribution( CommandContribution(
id: 'claude.agent.show', id: 'claude.agent.show',
command: 'claude.agent.show', command: 'claude.agent.show',
title: 'Claude: show an agent session pane', title: 'Claude: show an agent session pane',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.show(id); _orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
}, },
), ),
CommandContribution( CommandContribution(
id: 'claude.agent.hide', id: 'claude.agent.hide',
command: 'claude.agent.hide', command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane', title: 'Claude: hide an agent session pane',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.hide(id); _orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
}, },
), ),
CommandContribution( CommandContribution(
id: 'claude.agent.close', id: 'claude.agent.close',
command: 'claude.agent.close', command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session', title: 'Claude: close (kill) an agent session',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
await _orchestrator?.close(id); await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
}, },
), ),
CommandContribution( CommandContribution(
id: 'claude.agent.mute', id: 'claude.agent.mute',
command: 'claude.agent.mute', command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session', title: 'Claude: mute broker delivery to an agent session',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.mute(id); _orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
}, },
), ),
CommandContribution( CommandContribution(
id: 'claude.agent.unmute', id: 'claude.agent.unmute',
command: 'claude.agent.unmute', command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session', title: 'Claude: unmute broker delivery to an agent session',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
_orchestrator?.unmute(id); _orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
}, },
), ),
// Usage: clide claude.agent.inject-message <sessionId> <text...> // Usage: clide claude.agent.inject-message <sessionId> <text...>
CommandContribution( CommandContribution(
id: 'claude.agent.inject-message', id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message', command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session', title: 'Claude: inject a text turn into an agent session',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final text = args.skip(1).join(' '); final text = args.skip(1).join(' ');
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'}); if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
_orchestrator?.injectMessage(id, text); _orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'}); return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
}, },
), ),
// T-181: set permission mode for an agent session (D-6 CLI/UI parity). // T-181: set permission mode for an agent session (D-6 CLI/UI parity).
// Usage: clide claude.agent.set-permission-mode <sessionId> <mode> // Usage: clide claude.agent.set-permission-mode <sessionId> <mode>
// <mode> must be one of: default, acceptEdits, plan, bypassPermissions. // <mode> must be one of: default, acceptEdits, plan, bypassPermissions.
// Note: bypassPermissions is accepted via CLI — the footgun guard is the // Note: bypassPermissions is accepted via CLI — the footgun guard is the
// UI's confirm dialog; the CLI caller is responsible for their own safety. // UI's confirm dialog; the CLI caller is responsible for their own safety.
CommandContribution( CommandContribution(
id: 'claude.agent.set-permission-mode', id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode', command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session', title: 'Claude: set permission mode for an agent session',
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'}); if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
final mode = args.length >= 2 ? args[1] : null; final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'}); if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'}; const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) { if (!valid.contains(mode)) {
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'}); return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
} }
_orchestrator?.byId(id)?.session.setPermissionMode(mode); _orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'}); return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
}, },
), ),
// T-226: cycle the primary session's permission mode through the safe // T-226: cycle the primary session's permission mode through the safe
// trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M. // trio. Palette-discoverable counterpart to the composer's Ctrl/Cmd+M.
CommandContribution( CommandContribution(
id: 'claude.mode.cycle', id: 'claude.mode.cycle',
command: 'claude.mode.cycle', command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode', title: 'Claude: Cycle permission mode',
run: (_) async { run: (_) async {
final managed = _orchestrator?.byId('primary'); final managed = _orchestrator?.byId('primary');
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'}); if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default'); final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next); managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'}); return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
}, },
), ),
// Usage: clide claude.task.reassign <taskId> <toSessionId> // Usage: clide claude.task.reassign <taskId> <toSessionId>
CommandContribution( CommandContribution(
id: 'claude.task.reassign', id: 'claude.task.reassign',
command: 'claude.task.reassign', command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent', title: 'Claude: reassign a shared task to an agent',
run: (args) async { run: (args) async {
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'}); if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
final taskId = args[0]; final taskId = args[0];
final toId = args[1]; final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false; final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok}); return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
}, },
), ),
// T-180: full team chat pane opened as a workspace tab. // T-180: full team chat pane opened as a workspace tab.
// Shares the TeamChatModel with the sidebar widget. // Shares the TeamChatModel with the sidebar widget.
TabContribution( TabContribution(
id: 'claude.team-chat', id: 'claude.team-chat',
slot: Slots.workspace, slot: Slots.workspace,
title: 'Team Chat', title: 'Team Chat',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: 85, priority: 85,
build: (_) { build: (_) {
final orch = _orchestrator; final orch = _orchestrator;
if (orch == null) return const SizedBox.shrink(); if (orch == null) return const SizedBox.shrink();
return TeamChatPane(model: orch.chatModel, broker: orch.broker); return TeamChatPane(model: orch.chatModel, broker: orch.broker);
}, },
), ),
// CLI parity: open the team chat pane from the shell. // CLI parity: open the team chat pane from the shell.
// Usage: clide claude.team-chat.open // Usage: clide claude.team-chat.open
CommandContribution( CommandContribution(
id: 'claude.team-chat.open', id: 'claude.team-chat.open',
command: 'claude.team-chat.open', command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane', title: 'Claude: open the team chat pane',
run: (args) async { run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat'); _ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'}); return IpcResponse.ok(id: '', data: const {'status': 'opened'});
}, },
), ),
// Usage: clide claude.team-chat.post [@name] <text...> // Usage: clide claude.team-chat.post [@name] <text...>
// Posts a message into the broker channel as the user. // Posts a message into the broker channel as the user.
// Leading @name tag selects the recipient; omit for broadcast. // Leading @name tag selects the recipient; omit for broadcast.
CommandContribution( CommandContribution(
id: 'claude.team-chat.post', id: 'claude.team-chat.post',
command: 'claude.team-chat.post', command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user', title: 'Claude: post a message into the team channel as the user',
run: (args) async { run: (args) async {
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'}); if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
final raw = args.join(' '); final raw = args.join(' ');
String? recipient; String? recipient;
String body = raw; String body = raw;
if (raw.startsWith('@')) { if (raw.startsWith('@')) {
final ws = raw.indexOf(RegExp(r'\s')); final ws = raw.indexOf(RegExp(r'\s'));
if (ws > 0) { if (ws > 0) {
final tag = raw.substring(1, ws); final tag = raw.substring(1, ws);
recipient = (tag == 'team' || tag.isEmpty) ? null : tag; recipient = (tag == 'team' || tag.isEmpty) ? null : tag;
body = raw.substring(ws).trim(); body = raw.substring(ws).trim();
} }
} }
_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
// (T-172, D-6 CLI/UI parity for the roster fork button). // (T-172, D-6 CLI/UI parity for the roster fork button).
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>] // Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork. // <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: optional working directory; defaults to the source session's cwd. // <cwd>: optional working directory; defaults to the source session's cwd.
CommandContribution( CommandContribution(
id: 'claude.agent.fork', id: 'claude.agent.fork',
command: 'claude.agent.fork', command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session', title: 'Claude: fork a managed session into a new branch session',
run: (args) async { run: (args) async {
final sourceId = args.firstOrNull; final sourceId = args.firstOrNull;
if (sourceId == null) { if (sourceId == null) {
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'}); return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
} }
final orch = _orchestrator; final orch = _orchestrator;
if (orch == null) { if (orch == null) {
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'}); return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
} }
final source = orch.byId(sourceId); final source = orch.byId(sourceId);
if (source == null) { if (source == null) {
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'}); return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
} }
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, return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
role: 'fork of $sourceId', },
sessionId: forkId, ),
cwd: cwd, // Always-pickable left-panel tab: Claude activity (from
forkSourceSessionId: source.sessionId, // stats-cache.json) + the team roster when a team is running (T-141).
)); TabContribution(
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'}); id: 'claude.meta',
}, slot: Slots.sidebar,
), title: 'Activity',
// Always-pickable left-panel tab: Claude activity (from icon: PhosphorIcons.byName('robot'),
// stats-cache.json) + the team roster when a team is running (T-141). priority: 60,
TabContribution( build: (_) => const ClaudeMetaSidebar(),
id: 'claude.meta', ),
slot: Slots.sidebar, // In-pane status slot (T-145): the active Claude pane publishes
title: 'Activity', // its model · permission-mode · context line here.
icon: PhosphorIcons.byName('robot'), // flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
priority: 60, // yields width under pressure and ClideMarquee scrolls (T-160).
build: (_) => const ClaudeMetaSidebar(), StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
), ];
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
];
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
@@ -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(
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}', ImageMessage(
timestamp: DateTime.now(), uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
isSidechain: false, timestamp: DateTime.now(),
path: path, isSidechain: false,
caption: m.data['caption'] as String?, path: path,
)); 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'});
} }
} }
+6 -16
View File
@@ -17,15 +17,11 @@ import 'package:flutter/widgets.dart';
/// Open [path] full-size in the lightbox via the kernel dialog router. /// Open [path] full-size in the lightbox via the kernel dialog router.
void openImageLightbox(BuildContext context, String path) { 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),
),
),
);
} }
Widget _placeholder(BuildContext context, double size) { Widget _placeholder(BuildContext context, double size) {
@@ -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),
),
), ),
), ),
), ),
@@ -67,24 +67,24 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
} }
List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [ List<ClideMenuEntry> _entries(SurfaceTokens tokens) => [
for (final m in kSafePermissionCycle) for (final m in kSafePermissionCycle)
ClideMenuItem( ClideMenuItem(
leading: permissionModeIcon(m), leading: permissionModeIcon(m),
color: permissionModeColor(m, tokens), color: permissionModeColor(m, tokens),
label: permissionModeLabel(m), label: permissionModeLabel(m),
active: m == widget.mode, active: m == widget.mode,
onSelect: () => widget.onSelect(m), onSelect: () => widget.onSelect(m),
), ),
const ClideMenuSeparator(), const ClideMenuSeparator(),
ClideMenuItem( ClideMenuItem(
leading: permissionModeIcon('bypassPermissions'), leading: permissionModeIcon('bypassPermissions'),
color: permissionModeColor('bypassPermissions', tokens), color: permissionModeColor('bypassPermissions', tokens),
label: permissionModeLabel('bypassPermissions'), label: permissionModeLabel('bypassPermissions'),
enabled: false, enabled: false,
active: widget.mode == 'bypassPermissions', active: widget.mode == 'bypassPermissions',
onSelect: () {}, onSelect: () {},
), ),
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -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)),
+52 -58
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(
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null), children: [
const Spacer(), ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
_chatInstead(tokens), const Spacer(),
]), _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(
ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)), children: [
const SizedBox(width: 8), ClideButton(label: ' Back', onPressed: () => setState(() => _step = _q.length - 1)),
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null), const SizedBox(width: 8),
]), ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
],
),
], ],
); );
} }
@@ -330,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
const SizedBox(height: 10), 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),
const Spacer(),
_chatInstead(tokens),
], ],
ClideButton( ),
label: last ? 'Review ' : 'Next ',
variant: ClideButtonVariant.primary,
onPressed: answered ? () => setState(() => _step++) : null,
),
const Spacer(),
_chatInstead(tokens),
]),
], ],
); );
} }
@@ -354,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
final done = _answer(i).isNotEmpty; final 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(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)), decoration: BoxDecoration(
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground), color: tokens.globalFocus.withValues(alpha: 0.18),
)); border: Border.all(color: tokens.statusInfo),
borderRadius: BorderRadius.circular(4),
),
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
),
);
} else { } 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,19 +568,14 @@ 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.
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding( Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
); );
// -- shared note / free-text field ------------------------------------------- // -- shared note / free-text field -------------------------------------------
@@ -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? ?? '', for (final o in (q['options'] as List? ?? const []))
q['header'] as String? ?? '', if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
q['multiSelect'] as bool? ?? false, ]),
[
for (final o in (q['options'] as List? ?? const []))
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
],
),
]; ];
} }
+11 -19
View File
@@ -13,13 +13,7 @@ import 'dart:io';
/// One session in the workspace, summarised for the picker. /// 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(
id: id, SessionSummary(
modified: stat.modified, id: id,
firstUser: bookends.first, modified: stat.modified,
lastUser: bookends.last, firstUser: bookends.first,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')), lastUser: bookends.last,
)); 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;
@@ -181,9 +174,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
/// Just the sessions a pane should currently render. /// Just the sessions a pane should currently render.
List<ManagedSession> get visibleSessions => [ List<ManagedSession> get visibleSessions => [
for (final m in _sessions.values) for (final m in _sessions.values)
if (m.visible) m, if (m.visible) m,
]; ];
ManagedSession? byId(String id) => _sessions[id]; ManagedSession? byId(String id) => _sessions[id];
@@ -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]),
),
), ),
], ],
), ),
+70 -63
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',
[ [
@@ -176,10 +172,10 @@ final class AllowTool extends ToolDecision {
final String? followUpNote; final String? followUpNote;
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'behavior': 'allow', 'behavior': 'allow',
'updatedInput': updatedInput, 'updatedInput': updatedInput,
if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions, if (updatedPermissions != null && updatedPermissions!.isNotEmpty) 'updatedPermissions': updatedPermissions,
}; };
} }
/// Deny the tool with a user-facing [message] (required by the protocol). /// Deny the tool with a user-facing [message] (required by the protocol).
@@ -310,15 +306,17 @@ class StreamJsonSession {
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent // 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(
'type': 'control_request', jsonEncode({
'request_id': 'init-${_localSeq++}', 'type': 'control_request',
'request': { 'request_id': 'init-${_localSeq++}',
'subtype': 'initialize', 'request': {
'hooks': <String, dynamic>{}, 'subtype': 'initialize',
'sdkMcpServers': [for (final s in _mcpServers) s.name], 'hooks': <String, dynamic>{},
}, 'sdkMcpServers': [for (final s in _mcpServers) s.name],
})); },
}),
);
} }
} }
@@ -448,15 +446,17 @@ class StreamJsonSession {
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{}; final 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(
promptId: rid, ToolPrompt(
toolName: toolName, promptId: rid,
displayName: request['display_name'] as String? ?? toolName, toolName: toolName,
description: request['description'] as String?, displayName: request['display_name'] as String? ?? toolName,
toolUseId: request['tool_use_id'] as String? ?? '', description: request['description'] as String?,
input: input, toolUseId: request['tool_use_id'] as String? ?? '',
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [], input: input,
)); 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(
'type': 'control_response', jsonEncode({
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'}, 'type': 'control_response',
})); 'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}),
);
} }
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named /// 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(
'type': 'control_response', jsonEncode({
'response': { 'type': 'control_response',
'subtype': 'success', 'response': {
'request_id': rid, 'subtype': 'success',
'response': {'mcp_response': mcpResponse} 'request_id': rid,
}, '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(
'type': 'control_response', jsonEncode({
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()}, 'type': 'control_response',
})); '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(
'type': 'user', jsonEncode({
'message': {'role': 'user', 'content': text}, 'type': 'user',
})); 'message': {'role': 'user', 'content': text},
_items.add(UserMessage( }),
uuid: 'local-${_localSeq++}', );
timestamp: DateTime.now(), _items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
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(
'type': 'control_request', jsonEncode({
'request_id': 'interrupt-${_localSeq++}', 'type': 'control_request',
'request': {'subtype': 'interrupt'}, 'request_id': 'interrupt-${_localSeq++}',
})); '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(
'type': 'control_request', jsonEncode({
'request_id': 'set-perm-${_localSeq++}', 'type': 'control_request',
'request': {'subtype': 'set_permission_mode', 'mode': mode}, 'request_id': 'set-perm-${_localSeq++}',
})); '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
+4 -4
View File
@@ -47,7 +47,7 @@ List<TaskItem> taskListFrom(List<ConversationItem> items) {
} }
TaskStatus _statusFrom(Object? raw) => switch (raw) { TaskStatus _statusFrom(Object? raw) => switch (raw) {
'in_progress' => TaskStatus.inProgress, 'in_progress' => TaskStatus.inProgress,
'completed' => TaskStatus.completed, 'completed' => TaskStatus.completed,
_ => TaskStatus.pending, _ => TaskStatus.pending,
}; };
+16 -32
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,25 +345,26 @@ 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');
} }
} }
Map<String, dynamic> _result(Map<String, dynamic> value) => { Map<String, dynamic> _result(Map<String, dynamic> value) => {
'content': [ 'content': [
{'type': 'text', 'text': jsonEncode(value)}, {'type': 'text', 'text': jsonEncode(value)},
], ],
'isError': value['ok'] == false, 'isError': value['ok'] == false,
}; };
Map<String, dynamic> _error(String message) => { Map<String, dynamic> _error(String message) => {
'content': [ 'content': [
{'type': 'text', 'text': message}, {'type': 'text', 'text': message},
], ],
'isError': true, 'isError': true,
}; };
} }
const _toolDefs = <Map<String, dynamic>>[ const _toolDefs = <Map<String, dynamic>>[
+1 -5
View File
@@ -29,11 +29,7 @@ typedef SessionResolver = StreamJsonSession? Function(String memberName);
/// Lifetime matches the orchestrator: created once, subscribed to the broker, /// 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);
} }
+16 -78
View File
@@ -26,12 +26,7 @@ import 'package:flutter/widgets.dart';
/// [onPopOut] is called when the user taps the pop-out icon to open the full /// [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 …',
),
), ),
), ),
], ],
@@ -439,8 +391,8 @@ class _ChatRow extends StatelessWidget {
final toLabel = message.broadcast final toLabel = message.broadcast
? '→ all' ? '→ all'
: message.to != null : message.to != null
? '${message.to}' ? '${message.to}'
: null; : null;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 3), padding: const EdgeInsets.only(bottom: 3),
@@ -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)),
],
),
), ),
], ],
), ),
+7 -4
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(
'ids': [id], 'pql.tickets.status',
'status': 'in_progress', args: {
}); 'ids': [id],
'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;
@@ -38,11 +38,11 @@ abstract final class ClaudeConversation {
/// Encode [status] for [agentId] as a [memberStatusChannel] message body. /// Encode [status] for [agentId] as a [memberStatusChannel] message body.
static Map<String, Object?> memberStatusData(String agentId, SessionStatus status) => { static Map<String, Object?> memberStatusData(String agentId, SessionStatus status) => {
'agentId': agentId, 'agentId': agentId,
if (status.model != null) 'model': status.model, if (status.model != null) 'model': status.model,
if (status.permissionMode != null) 'permissionMode': status.permissionMode, if (status.permissionMode != null) 'permissionMode': status.permissionMode,
if (status.contextTokens != null) 'contextTokens': status.contextTokens, if (status.contextTokens != null) 'contextTokens': status.contextTokens,
}; };
} }
class TranscriptPublisher { class TranscriptPublisher {
@@ -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, _reader = reader {
this.channel = ClaudeConversation.leadChannel,
}) : _messages = messages,
_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,
});
}); });
} }
+75 -70
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.
/// ///
@@ -242,11 +229,11 @@ class TranscriptReader {
String? projectsBase, String? projectsBase,
int? initialTailBytes, int? initialTailBytes,
String? file, String? file,
}) : _pollInterval = pollInterval, }) : _pollInterval = pollInterval,
_onWarn = onWarn ?? _defaultWarn, _onWarn = onWarn ?? _defaultWarn,
_projectsBase = projectsBase ?? _defaultProjectsBase(), _projectsBase = projectsBase ?? _defaultProjectsBase(),
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes, _initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes,
_explicitFile = file; _explicitFile = file;
final String workspacePath; final String workspacePath;
final Duration _pollInterval; final Duration _pollInterval;
@@ -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;
@@ -475,13 +455,13 @@ class SessionStatus {
/// Overlay [other]'s non-null fields onto this one. /// Overlay [other]'s non-null fields onto this one.
SessionStatus merge(SessionStatus other) => SessionStatus( SessionStatus merge(SessionStatus other) => SessionStatus(
model: other.model ?? model, model: other.model ?? model,
permissionMode: other.permissionMode ?? permissionMode, permissionMode: other.permissionMode ?? permissionMode,
contextTokens: other.contextTokens ?? contextTokens, contextTokens: other.contextTokens ?? contextTokens,
cost: other.cost ?? cost, cost: other.cost ?? cost,
contextWindow: other.contextWindow ?? contextWindow, contextWindow: other.contextWindow ?? contextWindow,
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo, rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -509,13 +489,13 @@ class _StatusAcc {
int? contextWindow; int? contextWindow;
String? rateLimitInfo; String? rateLimitInfo;
SessionStatus toStatus() => SessionStatus( SessionStatus toStatus() => SessionStatus(
model: model, model: model,
permissionMode: permissionMode, permissionMode: permissionMode,
contextTokens: contextTokens, contextTokens: contextTokens,
cost: cost, cost: cost,
contextWindow: contextWindow, contextWindow: contextWindow,
rateLimitInfo: rateLimitInfo, rateLimitInfo: rateLimitInfo,
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -550,8 +530,10 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion; final 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,16 +635,18 @@ 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(
uuid: uuid, ToolResultMessage(
timestamp: timestamp, uuid: uuid,
isSidechain: isSidechain, timestamp: timestamp,
parentUuid: parentUuid, isSidechain: isSidechain,
parentToolUseId: parentToolUseId, parentUuid: parentUuid,
toolUseId: item['tool_use_id'] as String? ?? '', parentToolUseId: parentToolUseId,
content: rawContent is String ? rawContent : jsonEncode(rawContent), toolUseId: item['tool_use_id'] as String? ?? '',
isError: item['is_error'] as bool? ?? false, content: rawContent is String ? rawContent : jsonEncode(rawContent),
)); isError: item['is_error'] as bool? ?? false,
),
);
default: default:
break; break;
} }
@@ -689,27 +676,45 @@ 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(
uuid: uuid, AssistantToolUse(
timestamp: timestamp, uuid: uuid,
isSidechain: isSidechain, timestamp: timestamp,
parentUuid: parentUuid, isSidechain: isSidechain,
parentToolUseId: parentToolUseId, parentUuid: parentUuid,
toolUseId: item['id'] as String? ?? '', parentToolUseId: parentToolUseId,
name: item['name'] as String? ?? '', toolUseId: item['id'] as String? ?? '',
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{}, name: item['name'] as String? ?? '',
)); input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
),
);
default: default:
break; break;
} }
+19 -26
View File
@@ -61,30 +61,23 @@ class CliInstallExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
CommandContribution( CommandContribution(
id: 'clide.installCli', id: 'clide.installCli',
command: 'clide.installCli', command: 'clide.installCli',
title: "clide: Install 'clide' command in PATH", title: "clide: Install 'clide' command in PATH",
run: (_) async { run: (_) async {
final r = _resolved.install(); final r = _resolved.install();
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');
}); return IpcResponse.err(
} id: '',
ctx?.notify.error(r.message, title: 'clide CLI install failed'); error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
return IpcResponse.err( );
id: '', },
error: IpcError( ),
code: IpcExitCode.toolError, ];
kind: IpcErrorKind.toolError,
message: r.message,
),
);
},
),
];
} }
+8 -20
View File
@@ -1,34 +1,22 @@
import 'dart:ui' show Color; 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;
final Color rejected; final Color rejected;
Color forType(String? type) => switch (type) { Color forType(String? type) => switch (type) {
'confirmed' => confirmed, 'confirmed' => confirmed,
'question' => question, 'question' => question,
'rejected' => rejected, 'rejected' => rejected,
_ => 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),
+63 -20
View File
@@ -23,6 +23,7 @@ class _DecisionsViewState extends State<DecisionsView> {
StreamSubscription<Message>? _focusSub; StreamSubscription<Message>? _focusSub;
StreamSubscription<DaemonEvent>? _fileSub; StreamSubscription<DaemonEvent>? _fileSub;
StreamSubscription<SchedulerTick>? _schedulerSub; StreamSubscription<SchedulerTick>? _schedulerSub;
StreamSubscription<ProjectOpened>? _projectSub;
bool _refreshing = false; bool _refreshing = false;
bool _pendingRefresh = false; bool _pendingRefresh = false;
@@ -37,6 +38,12 @@ class _DecisionsViewState extends State<DecisionsView> {
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? '')) .where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
.listen((_) => _refresh()); .listen((_) => _refresh());
_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 daemon (the boot workDir is the launch CWD, not the repo), so pql
// runs against the wrong/old DB and the list errors. Re-fetch once the
// workspace is actually open — ProjectOpened fires after the IPC server
// swaps to the project workRoot. (T-352)
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
} }
if (!_loading || _decisions.isNotEmpty) return; if (!_loading || _decisions.isNotEmpty) return;
unawaited(_load()); unawaited(_load());
@@ -88,6 +95,7 @@ class _DecisionsViewState extends State<DecisionsView> {
_focusSub?.cancel(); _focusSub?.cancel();
_fileSub?.cancel(); _fileSub?.cancel();
_schedulerSub?.cancel(); _schedulerSub?.cancel();
_projectSub?.cancel();
super.dispose(); super.dispose();
} }
@@ -131,12 +139,14 @@ 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.id.toLowerCase().contains(lf) || (d) =>
d.title.toLowerCase().contains(lf) || d.id.toLowerCase().contains(lf) ||
(d.domain ?? '').toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) ||
(d.type ?? '').contains(lf)) (d.domain ?? '').toLowerCase().contains(lf) ||
.toList() (d.type ?? '').contains(lf),
)
.toList()
: _decisions; : _decisions;
final confirmed = filtered.where((d) => d.type == 'confirmed').toList(); final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
@@ -150,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(
@@ -172,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,
),
], ],
), ),
], ],
@@ -225,12 +264,12 @@ class _DecisionEntry {
final String? status; final String? status;
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry( factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
type: json['type'] as String?, type: json['type'] as String?,
domain: json['domain'] as String?, domain: json['domain'] as String?,
status: json['status'] as String?, status: json['status'] as String?,
); );
} }
class _DecisionCard extends StatelessWidget { class _DecisionCard extends StatelessWidget {
@@ -263,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),
+17 -17
View File
@@ -37,21 +37,21 @@ class DecisionsExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'decisions.panel', id: 'decisions.panel',
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Decisions', title: 'Decisions',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
icon: PhosphorIcons.byName('lightbulb'), icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionsView(), build: (_) => const DecisionsView(),
), ),
TabContribution( TabContribution(
id: 'decisions.detail', id: 'decisions.detail',
slot: Slots.contextPanel, slot: Slots.contextPanel,
title: 'Decision', title: 'Decision',
icon: PhosphorIcons.byName('lightbulb'), icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionDetailView(), build: (_) => const DecisionDetailView(),
), ),
]; ];
} }
+3 -3
View File
@@ -23,9 +23,9 @@ class DeepLinkAction {
/// A human-readable description for the confirmation prompt. /// A human-readable description for the confirmation prompt.
String get describe => switch (name) { String get describe => switch (name) {
'open' => 'Open $path${line != null ? ' (line $line)' : ''}', 'open' => 'Open $path${line != null ? ' (line $line)' : ''}',
_ => name, _ => name,
}; };
} }
/// Parse [url] into a [DeepLinkAction], or null when it is malformed, not a /// Parse [url] into a [DeepLinkAction], or null when it is malformed, not a
+2 -7
View File
@@ -25,13 +25,8 @@ class DeepLinkExtension extends ClideExtension {
@override @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
Future<void> activate(ClideExtensionContext ctx) async => _ctx = ctx; Future<void> activate(ClideExtensionContext ctx) async => _ctx = ctx;
+51 -102
View File
@@ -15,101 +15,54 @@ 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', // Collapse toggles (D-051, D-054)
title: 'Layout: Reset to Classic', CommandContribution(
run: _reset, id: 'sidebar.collapse',
), command: 'sidebar.collapse',
CommandContribution( title: 'Toggle Sidebar Collapse',
id: 'palette.toggle', defaultBinding: 'ctrl+shift+1',
command: 'palette.toggle', run: _collapseSidebar,
title: 'Command Palette', ),
defaultBinding: 'ctrl+shift+p', CommandContribution(
run: _togglePalette, id: 'context.collapse',
), command: 'context.collapse',
// Collapse toggles (D-051, D-054) title: 'Toggle Context Panel Collapse',
CommandContribution( defaultBinding: 'ctrl+shift+3',
id: 'sidebar.collapse', run: _collapseContext,
command: 'sidebar.collapse', ),
title: 'Toggle Sidebar Collapse', // Panel focus (D-054)
defaultBinding: 'ctrl+shift+1', CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
run: _collapseSidebar, CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
), CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
CommandContribution( // Focus mode (D-052, D-054)
id: 'context.collapse', CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
command: 'context.collapse', CommandContribution(
title: 'Toggle Context Panel Collapse', id: 'panel.focusMode.exit',
defaultBinding: 'ctrl+shift+3', command: 'panel.focusMode.exit',
run: _collapseContext, title: 'Exit Focus Mode',
), defaultBinding: 'escape',
// Panel focus (D-054) // Stand down in Vim insert/visual mode so Esc returns to normal
CommandContribution( // mode instead of closing the editor (T-257). Symmetric with
id: 'panel.focus.left', // vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
command: 'panel.focus.left', bindingWhen: '!vim.insert && !vim.visual',
title: 'Focus Left Panel', run: _exitFocusMode,
defaultBinding: 'ctrl+1', ),
run: _focusLeft, // Editor split (D-049, D-054)
), CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution( CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
id: 'panel.focus.middle', // Sidebar section switching (D-054): alt+1 through alt+5
command: 'panel.focus.middle', for (var i = 0; i < 5; i++)
title: 'Focus Middle Panel', CommandContribution(
defaultBinding: 'ctrl+2', id: 'sidebar.section.${i + 1}',
run: _focusMiddle, command: 'sidebar.section.${i + 1}',
), title: 'Sidebar: Section ${i + 1}',
CommandContribution( defaultBinding: 'alt+${i + 1}',
id: 'panel.focus.right', run: (args) => _switchSidebarSection(i),
command: 'panel.focus.right', ),
title: 'Focus Right Panel', ];
defaultBinding: 'ctrl+3',
run: _focusRight,
),
// Focus mode (D-052, D-054)
CommandContribution(
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution(
id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit',
title: 'Exit Focus Mode',
defaultBinding: 'escape',
// Stand down in Vim insert/visual mode so Esc returns to normal
// mode instead of closing the editor (T-257). Symmetric with
// vim.yaml's `vim.mode.normal` (escape when vim.insert||vim.visual).
bindingWhen: '!vim.insert && !vim.visual',
run: _exitFocusMode,
),
// Editor split (D-049, D-054)
CommandContribution(
id: 'editor.open',
command: 'editor.open',
title: 'Open Editor',
defaultBinding: 'ctrl+e',
run: _openEditor,
),
CommandContribution(
id: 'editor.close',
command: 'editor.close',
title: 'Close Editor',
defaultBinding: 'ctrl+w',
run: _closeEditor,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
id: 'sidebar.section.${i + 1}',
command: 'sidebar.section.${i + 1}',
title: 'Sidebar: Section ${i + 1}',
defaultBinding: 'alt+${i + 1}',
run: (args) => _switchSidebarSection(i),
),
];
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
@@ -313,11 +266,7 @@ 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) for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
_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,
),
), ),
], ],
), ),
+10 -10
View File
@@ -24,16 +24,16 @@ class DiffExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'diff.view', id: 'diff.view',
slot: Slots.workspace, slot: Slots.workspace,
title: 'Diff', title: 'Diff',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: -70, priority: -70,
build: (_) => DiffView(controller: _controller), build: (_) => DiffView(controller: _controller),
), ),
]; ];
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
+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 {
+14 -23
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;
} }
} }
@@ -413,11 +403,12 @@ class _RulerPainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
canvas.drawLine( canvas.drawLine(
Offset(x, 0), Offset(x, 0),
Offset(x, size.height), Offset(x, size.height),
Paint() Paint()
..color = color ..color = color
..strokeWidth = 1); ..strokeWidth = 1,
);
} }
@override @override
+10 -10
View File
@@ -50,14 +50,14 @@ class EditorExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'editor.active', id: 'editor.active',
slot: Slots.workspace, slot: Slots.workspace,
title: 'Editor', title: 'Editor',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: 80, // between Claude (90) and welcome (-100) priority: 80, // between Claude (90) and welcome (-100)
build: (_) => const EditorView(), build: (_) => const EditorView(),
), ),
]; ];
} }
@@ -36,18 +36,23 @@ class SyntaxTextController extends TextEditingController {
if (source == _highlightedText) return; if (source == _highlightedText) return;
_highlighting = true; _highlighting = true;
_syntax.highlight(path, source).then((result) { _syntax
_highlighting = false; .highlight(path, source)
if (text != source) { .then(
_requestHighlight(); (result) {
return; _highlighting = false;
} if (text != source) {
_highlightedText = source; _requestHighlight();
_spans = result.spans; return;
notifyListeners(); }
}, onError: (_) { _highlightedText = source;
_highlighting = false; _spans = result.spans;
}); notifyListeners();
},
onError: (_) {
_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(
text: source.substring(spanCharStart, spanCharEnd), TextSpan(
style: style?.copyWith( text: source.substring(spanCharStart, spanCharEnd),
color: TreeSitterService.colorForRole(span.role, tokens), style: style?.copyWith(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);
+72 -26
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,13 +274,19 @@ 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(
enterInsert: true, text: t,
); selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length)),
),
enterInsert: true,
);
VimResult _deleteChar(String t, int caret, int count) { VimResult _deleteChar(String t, int caret, int count) {
final le = _lineEnd(t, caret); final le = _lineEnd(t, caret);
@@ -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)),
); );
} }
+11 -11
View File
@@ -19,15 +19,15 @@ class FilesExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'files.tree', id: 'files.tree',
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Files', title: 'Files',
icon: PhosphorIcons.byName('folder'), icon: PhosphorIcons.byName('folder'),
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: -100, priority: -100,
build: (_) => const FileTreeView(), build: (_) => const FileTreeView(),
), ),
]; ];
} }
+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,
),
), ),
], ],
), ),
+12 -16
View File
@@ -16,20 +16,16 @@ class GitExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'git.panel', id: 'git.panel',
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Git', title: 'Git',
icon: PhosphorIcons.byName('git-branch'), icon: PhosphorIcons.byName('git-branch'),
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
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;
} }
+77 -206
View File
@@ -77,90 +77,58 @@ class _GitPanelViewState extends State<GitPanelView> {
children: [ children: [
ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)), ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_BranchHeader(controller: c), _BranchHeader(controller: c),
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, if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
fontSize: clideFontCaption, if (!c.loading && c.isClean && c.error == null)
maxLines: 3, const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
entries: _applyFilter(c.staged),
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
],
if (c.unstaged.isNotEmpty)
_FileGroup(
label: 'Changes',
entries: _applyFilter(c.unstaged),
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
if (c.untracked.isNotEmpty)
_FileGroup(
label: 'Untracked',
entries: _applyFilter(c.untracked),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () {
final paths = [for (final e in c.untracked) e['path'] as String];
unawaited(c.stage(paths));
},
),
],
onStage: (path) => unawaited(c.stage([path])),
), ),
),
if (c.loading && c.isClean)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
),
if (!c.loading && c.isClean && c.error == null)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Nothing to commit, working tree clean.', muted: true),
),
if (c.conflicted.isNotEmpty)
_FileGroup(
label: 'Merge conflicts',
entries: _applyFilter(c.conflicted),
actions: const [],
),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
entries: _applyFilter(c.staged),
actions: [
_GroupAction(
label: 'Unstage all',
onTap: () => unawaited(c.unstage(const [])),
),
],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(
commitMsg: _commitMsg,
commitFocus: _commitFocus,
controller: c,
),
], ],
if (c.unstaged.isNotEmpty) ),
_FileGroup(
label: 'Changes',
entries: _applyFilter(c.unstaged),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () => unawaited(c.stageAll()),
),
],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
if (c.untracked.isNotEmpty)
_FileGroup(
label: 'Untracked',
entries: _applyFilter(c.untracked),
actions: [
_GroupAction(
label: 'Stage all',
onTap: () {
final paths = [
for (final e in c.untracked) e['path'] as String,
];
unawaited(c.stage(paths));
},
),
],
onStage: (path) => unawaited(c.stage([path])),
),
],
), ),
)), ),
], ],
), ),
); );
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
child: Row( 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(
if (hash != null) commitMsg.clear(); controller.commit(msg).then((hash) {
})); 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) ...[ for (final a in actions) ...[_SmallAction(label: a.label, onTap: a.onTap), const SizedBox(width: 4)],
_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),
+11 -11
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(
'argv': ['search', '--connections', '--limit', '50'], 'pql.exec',
}); args: {
'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,
@@ -84,10 +84,10 @@ class _GraphNode {
final int outbound; final int outbound;
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode( factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
path: json['path'] as String? ?? json['relative_path'] as String? ?? '', path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0, inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0, outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
); );
} }
class _NodeRow extends StatelessWidget { class _NodeRow extends StatelessWidget {
+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),
], ],
+12 -17
View File
@@ -28,24 +28,19 @@ 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 => [
for (final entry in _presets.entries) for (final entry in _presets.entries)
CommandContribution( CommandContribution(
id: 'keymap.preset.${entry.key}', id: 'keymap.preset.${entry.key}',
command: 'keymap.preset.${entry.key}', command: 'keymap.preset.${entry.key}',
title: entry.value, title: entry.value,
run: (_) async { run: (_) async {
await _keymap?.setPreset(entry.key); await _keymap?.setPreset(entry.key);
return IpcResponse.ok(id: '', data: {'preset': entry.key}); return IpcResponse.ok(id: '', data: {'preset': entry.key});
}, },
), ),
]; ];
} }
+8 -8
View File
@@ -19,14 +19,14 @@ class MarkdownExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'markdown.viewer', id: 'markdown.viewer',
slot: Slots.contextPanel, slot: Slots.contextPanel,
title: 'Markdown', title: 'Markdown',
icon: PhosphorIcons.byName('file-text'), icon: PhosphorIcons.byName('file-text'),
build: (_) => const MarkdownViewer(), build: (_) => const MarkdownViewer(),
), ),
]; ];
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
@@ -95,18 +95,12 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); 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),
+69 -59
View File
@@ -26,67 +26,77 @@ class MenuBarExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
CommandContribution( CommandContribution(
id: 'file.openFolder', id: 'file.openFolder',
command: 'file.openFolder', command: 'file.openFolder',
title: 'File: Open Folder…', title: 'File: Open Folder…',
run: (_) async { run: (_) async {
await _file.openFolder(); await _file.openFolder();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
}, },
), ),
CommandContribution( CommandContribution(
id: 'file.newWindow', id: 'file.newWindow',
command: 'file.newWindow', command: 'file.newWindow',
title: 'File: New Window', title: 'File: New Window',
run: (_) async { run: (_) async {
_file.newWindow(); _file.newWindow();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
}, },
), ),
CommandContribution( CommandContribution(
id: 'file.closeWorkspace', id: 'file.closeWorkspace',
command: 'file.closeWorkspace', command: 'file.closeWorkspace',
title: 'File: Close Project', title: 'File: Close Project',
run: (_) async { run: (_) async {
_file.closeWorkspace(); _file.closeWorkspace();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
}, },
), ),
CommandContribution( CommandContribution(
id: 'help.about', id: 'help.about',
command: 'help.about', command: 'help.about',
title: 'Help: About clide', title: 'Help: About clide',
run: (_) async { run: (_) async {
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss())); services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
}, },
), ),
]; ];
} }
/// 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(
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'), title: 'File',
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'), mnemonic: 0,
const MenuSeparator(), nodes: [
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen), const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
]), const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
TopMenu(title: 'View', mnemonic: 0, nodes: const [ const MenuSeparator(),
MenuCommandItem('view.zoomIn'), MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
MenuCommandItem('view.zoomOut'), ],
MenuCommandItem('view.zoomReset'), ),
MenuSeparator(), TopMenu(
MenuCommandItem('sidebar.collapse'), title: 'View',
MenuCommandItem('context.collapse'), mnemonic: 0,
MenuCommandItem('dock.toggle'), nodes: const [
MenuCommandItem('panel.focusMode'), MenuCommandItem('view.zoomIn'),
MenuSeparator(), MenuCommandItem('view.zoomOut'),
MenuAutoFill('view.'), MenuCommandItem('view.zoomReset'),
]), MenuSeparator(),
TopMenu(title: 'Help', mnemonic: 0, nodes: const [ MenuCommandItem('sidebar.collapse'),
MenuCommandItem('help.about', fallbackTitle: 'About clide'), MenuCommandItem('context.collapse'),
]), MenuCommandItem('dock.toggle'),
]; MenuCommandItem('panel.focusMode'),
MenuSeparator(),
MenuAutoFill('view.'),
],
),
TopMenu(
title: 'Help',
mnemonic: 0,
nodes: const [MenuCommandItem('help.about', fallbackTitle: 'About clide')],
),
];
+3 -12
View File
@@ -143,10 +143,7 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
onSubmitted: (_) => unawaited(_submit()), 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,
),
), ),
), ),
), ),
+5 -12
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)
@@ -140,12 +135,10 @@ 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 [
for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]), for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]),
+2 -2
View File
@@ -60,8 +60,8 @@ class _DockStatusItemState extends State<DockStatusItem> {
final (String badge, Color color) = errors > 0 final (String badge, Color color) = errors > 0
? ('$errors', tokens.statusError) ? ('$errors', tokens.statusError)
: warns > 0 : warns > 0
? ('$warns', tokens.statusWarning) ? ('$warns', tokens.statusWarning)
: ('', tokens.statusSuccess); : ('', tokens.statusSuccess);
return Semantics( return Semantics(
button: true, button: true,
label: 'toggle output dock', label: 'toggle output dock',
+30 -30
View File
@@ -30,34 +30,34 @@ class OutputExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'output.panel', id: 'output.panel',
slot: Slots.dock, slot: Slots.dock,
title: 'Output', title: 'Output',
priority: -100, // sort before Problems in the dock tab bar priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing), build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
), ),
StatusItemContribution( StatusItemContribution(
id: 'output.dock-toggle', id: 'output.dock-toggle',
priority: 100, // right group, replacing the old app-status item priority: 100, // right group, replacing the old app-status item
build: (_) => const DockStatusItem(), build: (_) => const DockStatusItem(),
), ),
CommandContribution( CommandContribution(
id: 'dock.toggle', id: 'dock.toggle',
command: 'dock.toggle', command: 'dock.toggle',
title: 'Toggle output dock', title: 'Toggle output dock',
defaultBinding: 'ctrl+j', defaultBinding: 'ctrl+j',
run: (_) async { run: (_) async {
final ctx = _ctx; final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {}); if (ctx == null) return IpcResponse.ok(id: '', data: const {});
final a = ctx.arrangement; final a = ctx.arrangement;
final opening = !a.isVisible(Slots.dock); final opening = !a.isVisible(Slots.dock);
a.setVisible(Slots.dock, opening); a.setVisible(Slots.dock, opening);
if (opening && ctx.panels.activeTabIn(Slots.dock) == null) { if (opening && ctx.panels.activeTabIn(Slots.dock) == null) {
ctx.panels.activateTab(Slots.dock, 'output.panel'); ctx.panels.activateTab(Slots.dock, 'output.panel');
} }
return IpcResponse.ok(id: '', data: {'dock': opening}); return IpcResponse.ok(id: '', data: {'dock': opening});
}, },
), ),
]; ];
} }
+17 -25
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(
@@ -248,9 +240,9 @@ class _LogRow extends StatelessWidget {
} }
Color _levelColor(LogLevel level, SurfaceTokens tokens) => switch (level) { Color _levelColor(LogLevel level, SurfaceTokens tokens) => switch (level) {
LogLevel.error => tokens.statusError, LogLevel.error => tokens.statusError,
LogLevel.warn => tokens.statusWarning, LogLevel.warn => tokens.statusWarning,
LogLevel.info => tokens.globalForeground, LogLevel.info => tokens.globalForeground,
LogLevel.debug || LogLevel.trace => tokens.globalTextMuted, LogLevel.debug || LogLevel.trace => tokens.globalTextMuted,
}; };
} }
+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) if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
const Padding( _LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
padding: EdgeInsets.all(12), _LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
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(
+9 -9
View File
@@ -17,13 +17,13 @@ class PqlExtension extends ClideExtension {
// tab (T-201); this extension keeps only the Backlinks context panel. // tab (T-201); this extension keeps only the Backlinks context panel.
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'pql.backlinks', id: 'pql.backlinks',
slot: Slots.contextPanel, slot: Slots.contextPanel,
title: 'Links', title: 'Links',
icon: PhosphorIcons.byName('link'), icon: PhosphorIcons.byName('link'),
priority: -80, priority: -80,
build: (_) => const BacklinksView(), build: (_) => const BacklinksView(),
), ),
]; ];
} }
+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) {
+4 -13
View File
@@ -59,8 +59,8 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
.on<DaemonEvent>() .on<DaemonEvent>()
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md')) .where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
.listen((_) { .listen((_) {
if (widget.controller.view == PqlView.markdown) unawaited(widget.controller.loadMarkdownFiles()); if (widget.controller.view == PqlView.markdown) unawaited(widget.controller.loadMarkdownFiles());
}); });
} }
@override @override
@@ -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,
),
), ),
), ),
); );
+12 -12
View File
@@ -14,16 +14,16 @@ class ProblemsExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
// Moved out of the sidebar into the bottom dock (D-87): no duplication, // Moved out of the sidebar into the bottom dock (D-87): no duplication,
// and the dock's width fits `severity · file:line · message` rows. // and the dock's width fits `severity · file:line · message` rows.
TabContribution( TabContribution(
id: 'problems.panel', id: 'problems.panel',
slot: Slots.dock, slot: Slots.dock,
title: 'Problems', title: 'Problems',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: -50, priority: -50,
build: (_) => const ProblemsView(), build: (_) => const ProblemsView(),
), ),
]; ];
} }
@@ -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,
),
), ),
], ],
), ),
+9 -9
View File
@@ -19,13 +19,13 @@ class SearchExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'search.findInFiles', id: 'search.findInFiles',
slot: Slots.sidebar, slot: Slots.sidebar,
title: 'Search', title: 'Search',
icon: PhosphorIcons.byName('magnifying-glass'), icon: PhosphorIcons.byName('magnifying-glass'),
priority: -90, priority: -90,
build: (_) => const SearchPanelView(), build: (_) => const SearchPanelView(),
), ),
]; ];
} }
@@ -86,15 +86,18 @@ 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(
'pattern': pattern, 'search.replace',
'regex': regex, args: {
'ignoreCase': ignoreCase, 'pattern': pattern,
'include': _split(includeGlobs), 'regex': regex,
'exclude': _split(excludeGlobs), 'ignoreCase': ignoreCase,
'replacement': replacement, 'include': _split(includeGlobs),
'apply': true, 'exclude': _split(excludeGlobs),
}); 'replacement': replacement,
'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;
+57 -71
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();
} }
@@ -138,26 +137,34 @@ class _SearchPanelViewState extends State<SearchPanelView> {
Row( Row(
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),
ClideFilterBox( ClideFilterBox(
address: 'search.findInFiles.include', address: 'search.findInFiles.include',
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(
TextSpan(text: line.substring(0, start)), style: _base,
TextSpan(text: line.substring(start, end), style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), children: [
TextSpan(text: line.substring(end)), TextSpan(text: line.substring(0, start)),
]), TextSpan(
text: line.substring(start, end),
style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold),
),
TextSpan(text: line.substring(end)),
],
),
); );
} }
@@ -403,12 +387,18 @@ class _MatchRow extends StatelessWidget {
RichText( 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),
), ),
), ),
+10 -10
View File
@@ -18,14 +18,14 @@ class TerminalExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
TabContribution( TabContribution(
id: 'terminal.pane', id: 'terminal.pane',
slot: Slots.workspace, slot: Slots.workspace,
title: 'Terminal', title: 'Terminal',
titleKey: 'tab.title', titleKey: 'tab.title',
i18nNamespace: id, i18nNamespace: id,
priority: 100, priority: 100,
build: (_) => const TerminalPane(), build: (_) => const TerminalPane(),
), ),
]; ];
} }
+13 -23
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(
'argv': [shell, '-l'], 'pane.spawn',
'kind': PaneKind.terminal.wire, args: {
'cwd': cwd, 'argv': [shell, '-l'],
'cols': _terminal.viewWidth, 'kind': PaneKind.terminal.wire,
'rows': _terminal.viewHeight, 'cwd': cwd,
}); 'cols': _terminal.viewWidth,
'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),
],
), ),
), ),
); );
+12 -33
View File
@@ -20,46 +20,25 @@ class ThemePickerExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
// 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', // Always-visible switcher in the far-right status bar (T-234).
command: 'theme.pick', // priority >= 100 places it in the right group; registered after
title: 'Settings…', // ipc-status so it sits to its right.
defaultBinding: 'ctrl+k', StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
run: _pick, ];
),
// Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right.
StatusItemContribution(
id: 'theme-picker.switcher',
priority: 110,
build: (_) => const ThemeSwitcherStatusItem(),
),
];
Future<IpcResponse> _pick(List<String> args) async { Future<IpcResponse> _pick(List<String> args) async {
final ctx = _ctx; final ctx = _ctx;
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),
),
], ],
), ),
); );

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