Compare commits
@@ -59,7 +59,10 @@
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)"
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add .)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ Follow these conventions whenever you create a commit in this repository. These
|
||||
|
||||
## Message style
|
||||
|
||||
- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`.
|
||||
This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) (per [D-37](../../../governance/decisions/process.md#d-37)).
|
||||
|
||||
- **First line:** `type(scope): imperative subject`, ≤ 72 characters **including** the prefix. Examples: `feat(settings): add Appearance font picker (T-460)`, `fix(ipc): reconnect after app reload`, `docs(readme): drop brittle version line`.
|
||||
- **Type:** one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`. Use `feat`/`fix` for user-visible behavior; `chore` for bookkeeping (`chore(plan)` is the convention for pql ticket housekeeping). Append `!` after the scope for a breaking change (`feat(ipc)!: …`).
|
||||
- **Scope (optional but preferred):** the subsystem the change lives in — `settings`, `vim`, `pty`, `git`, `plan`, etc. Lower-case, no spaces.
|
||||
- **Ticket ref:** keep a trailing `(T-NNN)` on the subject when the work has a ticket — `feat(settings): category rail + navigation (T-447)`.
|
||||
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
|
||||
- **No emojis.** Anywhere.
|
||||
- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.)
|
||||
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
|
||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the `clide` CLI is a thin C client (`native/clide-cli/`). The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||
|
||||
## Logically-separated commits
|
||||
|
||||
|
||||
@@ -36,8 +36,15 @@ These apply across every reference and every surface:
|
||||
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
||||
is `WidgetsApp` only (D-7).
|
||||
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
||||
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||
- Typography sizes: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||
status/section headers, body inherits from `DefaultTextStyle`.
|
||||
- Font *family* comes from the user-selectable facade, not a const: a
|
||||
monospace surface uses `fontFamily: ClideSettings.fonts.monoOf(context)`
|
||||
(and `fontFamilyFallback: clideMonoFamilyFallback`); the UI face is inherited
|
||||
via the root `DefaultTextStyle`, or `ClideSettings.fonts.uiOf(context)` when a
|
||||
widget must set it explicitly. `clideMonoFamily` / `clideUiFamily` are the
|
||||
facade's defaults — don't read them directly in new widgets (D-101). Same
|
||||
facade exposes `ClideSettings.theme.of(context)` and `.i18n.of(context)`.
|
||||
|
||||
## Conversation-panel cards (T-305)
|
||||
|
||||
|
||||
@@ -5805,3 +5805,127 @@ 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 ('06FD15TY946689VZK3MNABSMDW', 'description', NULL, 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', NULL, '2026-06-16 13:20:47', '2026-06-16 13:20:47', '2026-06-16 13:20:47', NULL, '0f2073250cde4ea3213f33008aec0cba', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'description', NULL, 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', NULL, '2026-06-16 13:21:00', '2026-06-16 13:21:00', '2026-06-16 13:21:00', NULL, '1efc3eadc7b93d57c205deddab67bbb3', 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 ('06FB0TNQM7H8JWCP59WQVD0FW4', 'status', 'in_progress', 'done', NULL, '2026-06-16 13:21:11', '2026-06-16 13:21:11', '2026-06-16 13:21:11', NULL, '6410fd78c4919240bb5eaba9daa4f807', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'description', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.
|
||||
|
||||
## Problem
|
||||
|
||||
The editor can introduce spurious EOL (LF↔CRLF) and EOF (trailing-newline) changes on save, producing cross-platform commit churn — a "race" where Linux/macOS and Windows contributors repeatedly flip a file''s line endings or final newline back and forth in commits even when they made no real edit to those lines.
|
||||
|
||||
## Current state / root cause
|
||||
|
||||
- **Read path** — `lib/src/editor/registry.dart:50` loads via `File.readAsString()` and stores the buffer as a plain string in `EditorBuffer` (`lib/src/editor/buffer.dart`). It records **neither** the original EOL style (LF / CRLF / CR) **nor** whether the file ended with a trailing newline.
|
||||
- **Save path** — `registry.dart:141` calls `EditorSettings.applyOnSave` (`lib/src/editor/editor_settings.dart:114`). That method only rewrites EOLs / final-newline when `.editorconfig` explicitly sets `end_of_line` / `insert_final_newline`; otherwise it returns the content unchanged. So byte-fidelity depends entirely on the buffer + text-editing widget round-trip faithfully preserving `\r\n` and the EOF state. Because the original state is never recorded on read, there is **no way to restore it** if any layer (the Flutter text controller / platform text input in `syntax_text_controller.dart` / `editor_controller.dart`) normalizes newlines.
|
||||
- **No `.gitattributes` backstop** — the only entry is `.pql/changelog/*.sql merge=union`; there is no `* text=auto` / `eol` rule. Whatever the editor writes lands verbatim in commits with no normalization safety net.
|
||||
|
||||
## Proposed hardening
|
||||
|
||||
1. On read, detect and record on the buffer: (a) dominant EOL style, (b) whether the file has mixed EOLs, (c) presence/absence of a final newline.
|
||||
2. On save, default to **preserving** the recorded EOL + EOF state when `.editorconfig` does not override (explicit `.editorconfig` settings still win). Mixed-EOL files preserved as-is unless explicit normalization is requested.
|
||||
3. Audit the text-editing round-trip (`syntax_text_controller.dart`, `editor_controller.dart`) for silent `\r` dropping or final-newline mutation; if found, normalize at the buffer boundary on read and restore on save.
|
||||
4. Add a repo `.gitattributes` (`* text=auto eol=lf` plus binary carve-outs) as defense-in-depth so commits do not churn even if the editor slips.
|
||||
5. Tests: round-trip CRLF, LF, no-final-newline, and mixed-EOL fixtures through open → edit → save and assert byte-identical output absent explicit settings. Exercise the Linux/macOS/Windows save paths.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Opening a file and saving it with no content edits produces a **byte-identical** file (no EOL/EOF diff) on Linux, macOS, and Windows regardless of the file''s original line endings — unless `.editorconfig` explicitly requests normalization.', NULL, '2026-06-16 14:12:09', '2026-06-16 14:12:09.834', '2026-06-16 14:12:09.834', NULL, 'add25fbe70d4ba9dbf3c4731bc00fc91', 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 ('06FD1JBRABJHX804CPMZJDC444', 'description', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
|
||||
|
||||
## Repro
|
||||
|
||||
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
|
||||
|
||||
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
|
||||
- **Actual:** the top split does not collapse cleanly — leftover/empty space or broken layout remains where the editor split was.
|
||||
|
||||
## Suspect code paths (root cause not yet confirmed)
|
||||
|
||||
- `lib/src/shell/slot_host.dart` — `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic — `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) — while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
|
||||
- `lib/kernel/src/panels/arrangement.dart` — `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
|
||||
- `lib/builtin/editor/src/extension.dart` — (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
|
||||
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
|
||||
|
||||
## Investigation / fix direction
|
||||
|
||||
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
|
||||
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column — independent of which tab is "active".
|
||||
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
|
||||
|
||||
## Tests
|
||||
|
||||
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', NULL, '2026-06-16 14:15:54', '2026-06-16 14:15:54.344', '2026-06-16 14:15:54.344', NULL, '2dbe866fe833666db5afbc7430c0d9c8', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'description', NULL, 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
|
||||
|
||||
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
|
||||
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
|
||||
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
|
||||
- Each option renders its label **in its own face** as an inline preview.
|
||||
|
||||
**Bake in Inter as the default UI font.**
|
||||
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
|
||||
|
||||
**Font registry + wiring (the real work):**
|
||||
- A small curated registry of bundled faces per role (UI: Inter + 1–2 others; mono: JetBrainsMono + 1–2 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
|
||||
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
|
||||
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
|
||||
|
||||
**Acceptance:**
|
||||
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
|
||||
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
|
||||
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
|
||||
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
|
||||
|
||||
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
|
||||
|
||||
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', NULL, '2026-06-17 07:39:55', '2026-06-17 07:39:55.628', '2026-06-17 07:39:55.628', NULL, '316143b93910994fac553f6f515f6645', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'description', NULL, 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781681999949.png). An API error renders as a plain conversation card with no error styling:
|
||||
|
||||
clide
|
||||
API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited
|
||||
|
||||
It should be visually flagged with a **red error border** on the card so it stands out from normal turns.
|
||||
|
||||
**Already supported — just needs wiring.** `lib/builtin/claude/src/conversation_card.dart`:
|
||||
- `ConversationCardStatus.error` exists (line ~302 → `tokens.statusError`) but today only tints the header status tick, not the border.
|
||||
- The `bordered` variant draws `Border.all(color: widget.borderColor ?? tokens.panelBorder)` (lines ~61 / ~224) — so the border defaults to the neutral `panelBorder`.
|
||||
|
||||
**Fix:** render the API-error card as `variant: bordered, status: error, borderColor: tokens.statusError` (or, cleaner, have the card derive the border from `status == error → tokens.statusError` so any error card gets the red border without each caller passing it). Wire the producer — the error / system message that carries the "API Error …" text (in `conversation_view.dart` / `transcript_reader.dart`, e.g. the stream error / `SessionEnd.reason` path) — to mark the card as an error.
|
||||
|
||||
**Acceptance:** an API error (rate-limit, server error, etc.) renders a card with a red `statusError` border; normal cards unchanged; a widget test asserts the error card''s border colour.
|
||||
|
||||
**Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', NULL, '2026-06-17 07:41:47', '2026-06-17 07:41:47.267', '2026-06-17 07:41:47.267', NULL, 'aa0330e9165b9b9fc3f1c516abc3a07c', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'status', 'backlog', 'done', NULL, '2026-06-17 09:21:55', '2026-06-17 09:21:55.041', '2026-06-17 09:21:55.041', NULL, 'cd1f735c94e8bca9adcd7d0f9cead676', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 09:50:39', '2026-06-17 09:50:39.449', '2026-06-17 09:50:39.449', NULL, 'c94b125eb458442b012bfb1a86860ae8', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'backlog', 'ready', NULL, '2026-06-17 09:50:52', '2026-06-17 09:50:52.176', '2026-06-17 09:50:52.176', NULL, '6fe266e126c9edb86fa2386093f33bd4', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'backlog', 'ready', NULL, '2026-06-17 09:50:52', '2026-06-17 09:50:52.665', '2026-06-17 09:50:52.665', NULL, 'e6a90cb467880a6a4e35e860ba416f99', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'backlog', 'ready', NULL, '2026-06-17 09:50:53', '2026-06-17 09:50:53.074', '2026-06-17 09:50:53.074', NULL, 'aa94551043d54ff96acad1054a095b5b', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:06:59', '2026-06-17 10:06:59.713', '2026-06-17 10:06:59.713', NULL, '8d93c1cb133b480fdd5e8407977df764', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:07:00', '2026-06-17 10:07:00.144', '2026-06-17 10:07:00.144', NULL, '18b1ae31f194073a8f6dbb7ca9d58963', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.725', '2026-06-17 10:09:41.725', NULL, 'af6eaeeae0e9e27a80ad26f9ab78c7c2', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.911', '2026-06-17 10:09:41.911', NULL, '5588d681ec1d926adbcdc378ef37eb47', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.353', '2026-06-17 10:16:22.353', NULL, '290e5da4942b778213253ef665c3a4b1', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:16:22', '2026-06-17 10:16:22.702', '2026-06-17 10:16:22.702', NULL, 'faabfb970849aacda3919f1501ba7bfd', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:22:42', '2026-06-17 10:22:42.544', '2026-06-17 10:22:42.544', NULL, 'b150c458bb2754f0369000e65406dec2', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 10:29:07', '2026-06-17 10:29:07.736', '2026-06-17 10:29:07.736', NULL, '431cacc8adfb10c478617292fac8776f', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:31:23', '2026-06-17 10:31:23.935', '2026-06-17 10:31:23.935', NULL, '9228abe9a3eb9b340c91717b4dade863', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 10:44:25', '2026-06-17 10:44:25.018', '2026-06-17 10:44:25.018', NULL, '4626e79f524f63e003c0f93511e8cdce', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:48:30', '2026-06-17 10:48:30.719', '2026-06-17 10:48:30.719', NULL, '3758ec372c9635bfd7756a8939f4b329', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 10:56:30', '2026-06-17 10:56:30.289', '2026-06-17 10:56:30.289', NULL, 'f4ca163e49ee68804c6d66bc059a6dfd', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'status', 'in_progress', 'done', NULL, '2026-06-17 11:04:11', '2026-06-17 11:04:11.562', '2026-06-17 11:04:11.562', NULL, '10855e5b574013427ec9a6872c674d1d', 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 ('06FD15TY946689VZK3MNABSMDW', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 11:21:19', '2026-06-17 11:21:19.581', '2026-06-17 11:21:19.581', NULL, '02d53470150602dbbcaecc165a4a3d63', 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 ('06FD15TY946689VZK3MNABSMDW', 'status', 'in_progress', 'done', NULL, '2026-06-17 11:43:37', '2026-06-17 11:43:37.615', '2026-06-17 11:43:37.615', NULL, 'a2f67644a293f12da9b0cdf28dc79055', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 12:08:37', '2026-06-17 12:08:37.084', '2026-06-17 12:08:37.084', NULL, '56dfbd21ec4ea2add7d34c1008d3690b', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'status', 'in_progress', 'ready', NULL, '2026-06-17 12:09:28', '2026-06-17 12:09:28.013', '2026-06-17 12:09:28.013', NULL, '04a42a6bb8bed47fda6da86038dfb15e', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'status', 'ready', 'in_progress', NULL, '2026-06-17 12:11:21', '2026-06-17 12:11:21.874', '2026-06-17 12:11:21.874', NULL, '20c4f05dc5ffc8a29a0e6c2f94dfaf4b', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'description', NULL, 'Follow-up to T-457. The Claude settings category sets new-session defaults (model/effort/permission) via generic schema selects — those persist + seed NEW sessions but don''t touch running ones. Add the ''Claude settings changes detected — apply to current sessions?'' prompt: on a default change with ≥1 running session, confirm via DialogRouter, then apply to all sessions through the existing builtin.claude/command channel (/model, /effort, /permissions). Needs a custom control (the generic select can''t prompt), so swap the Claude category''s selects for a custom-control widget per D-? / the T-452 escape hatch.', NULL, '2026-06-17 12:21:31', '2026-06-17 12:21:31.456', '2026-06-17 12:21:31.456', NULL, '799b77b6ec7862c856957d948326cffd', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'status', 'in_progress', 'done', NULL, '2026-06-17 12:21:39', '2026-06-17 12:21:39.311', '2026-06-17 12:21:39.311', NULL, 'dd983e8a5ccfae1b0dea46a8b5e78249', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 18:04:55', '2026-06-17 18:04:55.887', '2026-06-17 18:04:55.887', NULL, '227e7299a000813f50e7036f018322bf', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'description', NULL, 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', NULL, '2026-06-17 18:11:49', '2026-06-17 18:11:49.697', '2026-06-17 18:11:49.697', NULL, '54ec9200fb4cd124e3413a6e134575ea', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'status', 'in_progress', 'done', NULL, '2026-06-17 18:14:38', '2026-06-17 18:14:38.272', '2026-06-17 18:14:38.272', NULL, '9c7cb301a7539212e90bf16210684d97', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 18:18:13', '2026-06-17 18:18:13.607', '2026-06-17 18:18:13.607', NULL, 'b8f8460933a4b9393acc816c167d725d', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'status', 'in_progress', 'done', NULL, '2026-06-17 18:59:09', '2026-06-17 18:59:09.679', '2026-06-17 18:59:09.679', NULL, 'a088e76684e5d0c4922c62082802dd2d', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'description', NULL, 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', NULL, '2026-06-17 19:03:19', '2026-06-17 19:03:19.250', '2026-06-17 19:03:19.250', NULL, 'dcdd0348a9cd88a23ac1b3711d554c58', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'description', NULL, 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', NULL, '2026-06-17 19:03:21', '2026-06-17 19:03:21.095', '2026-06-17 19:03:21.095', NULL, '45e0fb561bb187552a423d098aeb1cb5', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'description', NULL, 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', NULL, '2026-06-17 19:03:22', '2026-06-17 19:03:22.235', '2026-06-17 19:03:22.235', NULL, '5d617ff30e22d3b4c57ca6325920d91a', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'status', 'backlog', 'done', NULL, '2026-06-17 19:35:22', '2026-06-17 19:35:22.690', '2026-06-17 19:35:22.690', NULL, '580448456994d6acecc88cfbd40a595f', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'status', 'backlog', 'done', NULL, '2026-06-17 19:37:39', '2026-06-17 19:37:39.813', '2026-06-17 19:37:39.813', NULL, 'd59d0df2b693a0616265dd5e39559dc0', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'status', 'backlog', 'done', NULL, '2026-06-17 19:47:02', '2026-06-17 19:47:02.941', '2026-06-17 19:47:02.941', NULL, '28625100d88cacac15cabe97754fcdc5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -283,3 +283,20 @@ 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 ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'T-455', '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '370e913c62eab739cf39bda08089e033', 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 ('06FD15TY946689VZK3MNABSMDW', 'T-456', '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '6041202d66dd93ee3d22206ece3a7db7', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'T-457', '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, 'c9bef415fb7d9d03444fde53ecac3bb1', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'T-458', '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, '070f1def635ee013df639cb9fe81e357', 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 ('06FD1JBRABJHX804CPMZJDC444', 'T-459', '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, '44d5db70ef13ead00c4dcc437c387cec', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'T-460', '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'e7e749d35dc413a560d3347be5f4a9ee', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'T-461', '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '4e3fba7aaad762b7b7cd854459cf664a', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'T-462', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4be5f7eb00ca81eb07015af05ed144d3', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'T-463', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, 'd5d02453175411c13d21361b084494bd', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'T-464', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, 'c787ef61befc6698286ebdaf613e2a41', 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 ('06FDA0X5WFME83GS0DKYG3WVJ8', 'T-465', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, '116b1a377950dfcef4e5b6db90141faa', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'T-466', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, 'd25bc26eb29445fc35cf53f3d54049e5', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'T-467', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a47c675ca67b86ca3c8d4c1a8acc5b80', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'T-468', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'b988d9578b1ca079c040f07265a008b4', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'T-469', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, '2a72e3e647afc1b5b235ab1a97d298f8', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'T-470', '2026-06-17 12:21:23.411', '2026-06-17 12:21:23.411', NULL, '0f95e5a1faa7a7e902c765f449cceb15', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'T-471', '2026-06-17 18:11:42.049', '2026-06-17 18:11:42.049', NULL, '10a9a957d647a63ce69dc4b6b564f6aa', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'T-472', '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '097120cff851cb2dac9f54937f4c7b17', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'T-473', '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '647782edc3fbb4e7285e4f82a1fb84be', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -7005,3 +7005,246 @@ the real .clide vs ~/.clide files), gear (collides with "settings"). Note that
|
||||
every Phosphor glyph already renders via PhosphorIconPainter(0xNNNN); only named
|
||||
consts need adding. Preview/picking is blocked on a native glyph card (see the
|
||||
new ticket) — goldens render the font as Ahem boxes.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:24:54', '2026-06-16 13:21:11', NULL, '1db1af71519321792537391a5f38b613', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, 'ba4504b18aed43d841f9eb57d5beae5a', 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 ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor hardens EOL/EOF fidelity to stop cross-platform commit churn', 'Editor can introduce spurious EOL (LF/CRLF) and EOF (final-newline) changes on save, causing cross-platform commit churn. Harden by recording original EOL/EOF state on read and preserving it on save, plus a .gitattributes backstop. Details appended below.
|
||||
|
||||
## Problem
|
||||
|
||||
The editor can introduce spurious EOL (LF↔CRLF) and EOF (trailing-newline) changes on save, producing cross-platform commit churn — a "race" where Linux/macOS and Windows contributors repeatedly flip a file''s line endings or final newline back and forth in commits even when they made no real edit to those lines.
|
||||
|
||||
## Current state / root cause
|
||||
|
||||
- **Read path** — `lib/src/editor/registry.dart:50` loads via `File.readAsString()` and stores the buffer as a plain string in `EditorBuffer` (`lib/src/editor/buffer.dart`). It records **neither** the original EOL style (LF / CRLF / CR) **nor** whether the file ended with a trailing newline.
|
||||
- **Save path** — `registry.dart:141` calls `EditorSettings.applyOnSave` (`lib/src/editor/editor_settings.dart:114`). That method only rewrites EOLs / final-newline when `.editorconfig` explicitly sets `end_of_line` / `insert_final_newline`; otherwise it returns the content unchanged. So byte-fidelity depends entirely on the buffer + text-editing widget round-trip faithfully preserving `\r\n` and the EOF state. Because the original state is never recorded on read, there is **no way to restore it** if any layer (the Flutter text controller / platform text input in `syntax_text_controller.dart` / `editor_controller.dart`) normalizes newlines.
|
||||
- **No `.gitattributes` backstop** — the only entry is `.pql/changelog/*.sql merge=union`; there is no `* text=auto` / `eol` rule. Whatever the editor writes lands verbatim in commits with no normalization safety net.
|
||||
|
||||
## Proposed hardening
|
||||
|
||||
1. On read, detect and record on the buffer: (a) dominant EOL style, (b) whether the file has mixed EOLs, (c) presence/absence of a final newline.
|
||||
2. On save, default to **preserving** the recorded EOL + EOF state when `.editorconfig` does not override (explicit `.editorconfig` settings still win). Mixed-EOL files preserved as-is unless explicit normalization is requested.
|
||||
3. Audit the text-editing round-trip (`syntax_text_controller.dart`, `editor_controller.dart`) for silent `\r` dropping or final-newline mutation; if found, normalize at the buffer boundary on read and restore on save.
|
||||
4. Add a repo `.gitattributes` (`* text=auto eol=lf` plus binary carve-outs) as defense-in-depth so commits do not churn even if the editor slips.
|
||||
5. Tests: round-trip CRLF, LF, no-final-newline, and mixed-EOL fixtures through open → edit → save and assert byte-identical output absent explicit settings. Exercise the Linux/macOS/Windows save paths.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Opening a file and saving it with no content edits produces a **byte-identical** file (no EOL/EOF diff) on Linux, macOS, and Windows regardless of the file''s original line endings — unless `.editorconfig` explicitly requests normalization.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:12:04.212', '2026-06-16 14:12:09.834', NULL, 'e5f4a3e6dc5b9c8977ef36d0a17f2b9e', 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 ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, 'ec93569d2ab51868382e04dc7711980b', 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 ('06FD1JBRABJHX804CPMZJDC444', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Editor top split in main column does not collapse when its last panel closes', 'Closing the last editor panel in the top split of the main column leaves an empty/broken region instead of collapsing so the primary pane fills the column. Suspect: asymmetric top/bottom split logic in slot_host.dart _WorkspaceSlot, and closeEditor() not switching the active tab off editor.active. Details appended below.
|
||||
|
||||
## Repro
|
||||
|
||||
In the main column (the center "workspace" slot), the editor renders as a **top split** above the primary pane (Claude). Close the last editor panel/buffer in that top split (e.g. `:q`, `ctrl+w`, or closing the last editor tab).
|
||||
|
||||
- **Expected:** the top split collapses and the remaining primary pane takes the full column height.
|
||||
- **Actual:** the top split does not collapse cleanly — leftover/empty space or broken layout remains where the editor split was.
|
||||
|
||||
## Suspect code paths (root cause not yet confirmed)
|
||||
|
||||
- `lib/src/shell/slot_host.dart` — `_WorkspaceSlot.build()` (~lines 147-206). The split is computed asymmetrically: the **top** region is dynamic — `topTab = reveal ?? (editorOpen ? editorTab : null)` (~line 176), collapsing only when `topTab == null` (~line 178) — while the **bottom** region is always `primaryPane = (claude ?? active)`. The top/bottom asymmetry is the prime suspect for why the top split specifically fails to collapse.
|
||||
- `lib/kernel/src/panels/arrangement.dart` — `closeEditor()` (~line 108) flips `editorOpen = false` but does not switch the workspace slot''s **active tab** away from `editor.active`. The editor tab stays registered, so a rebuild may still resolve `active`/`editorTab` to the editor and keep the split open.
|
||||
- `lib/builtin/editor/src/extension.dart` — (~lines 36-43) the `editor.active-changed` handler calls `ctx.arrangement.closeEditor()` when the last buffer closes (`id == null`). Confirm this fires and that the resulting rebuild actually reaches the collapse branch.
|
||||
- Possible rebuild-notification gap: `_WorkspaceSlot`''s `ListenableBuilder` listens to `kernel.arrangement`; verify active-tab changes (`kernel.panels`) also drive the rebuild that recomputes `topTab`.
|
||||
|
||||
## Investigation / fix direction
|
||||
|
||||
1. Reproduce in the running app and pin down which of the above is the actual cause (asymmetric top/bottom split logic vs. active tab not switching away from the closed editor).
|
||||
2. Make collapse symmetric and state-driven: when the editor top split has no remaining panel, the region must drop out so the primary pane fills the column — independent of which tab is "active".
|
||||
3. On editor close, ensure the workspace active tab is moved off `editor.active` (or that the split renderer ignores a closed editor tab regardless of active state).
|
||||
|
||||
## Tests
|
||||
|
||||
Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-16 14:15:54.344', NULL, '50f4b7916e89ac7e0804ae6669940e90', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'ad0c79b26f1b2c9dbdc9983daf7cb3ef', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
|
||||
|
||||
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
|
||||
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
|
||||
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
|
||||
- Each option renders its label **in its own face** as an inline preview.
|
||||
|
||||
**Bake in Inter as the default UI font.**
|
||||
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
|
||||
|
||||
**Font registry + wiring (the real work):**
|
||||
- A small curated registry of bundled faces per role (UI: Inter + 1–2 others; mono: JetBrainsMono + 1–2 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
|
||||
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
|
||||
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
|
||||
|
||||
**Acceptance:**
|
||||
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
|
||||
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
|
||||
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
|
||||
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
|
||||
|
||||
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
|
||||
|
||||
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 07:39:55.628', NULL, '0bd5fdb6f8d924a23eb45be2c48bedaf', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'API-error cards: flag with a red error border (statusError)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '38eac435cdb43c08db84de486c65a527', 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 ('06FD91RWDWZPHFPJ72FHHHWS08', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'API-error cards: flag with a red error border (statusError)', 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781681999949.png). An API error renders as a plain conversation card with no error styling:
|
||||
|
||||
clide
|
||||
API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited
|
||||
|
||||
It should be visually flagged with a **red error border** on the card so it stands out from normal turns.
|
||||
|
||||
**Already supported — just needs wiring.** `lib/builtin/claude/src/conversation_card.dart`:
|
||||
- `ConversationCardStatus.error` exists (line ~302 → `tokens.statusError`) but today only tints the header status tick, not the border.
|
||||
- The `bordered` variant draws `Border.all(color: widget.borderColor ?? tokens.panelBorder)` (lines ~61 / ~224) — so the border defaults to the neutral `panelBorder`.
|
||||
|
||||
**Fix:** render the API-error card as `variant: bordered, status: error, borderColor: tokens.statusError` (or, cleaner, have the card derive the border from `status == error → tokens.statusError` so any error card gets the red border without each caller passing it). Wire the producer — the error / system message that carries the "API Error …" text (in `conversation_view.dart` / `transcript_reader.dart`, e.g. the stream error / `SessionEnd.reason` path) — to mark the card as an error.
|
||||
|
||||
**Acceptance:** an API error (rate-limit, server error, etc.) renders a card with a red `statusError` border; normal cards unchanged; a widget test asserts the error card''s border colour.
|
||||
|
||||
**Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:47.266', NULL, 'e4c3db66fb4ba42ba08896b97ef3ad89', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-17 09:21:55.041', NULL, '82094d971c514c6524dd0c2197d171a4', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 09:50:39.449', NULL, '6511e1989b01d0928e9128d691d7725c', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 09:50:52.176', NULL, '2e229c01974646fbb2c44e746d36171c', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 09:50:52.665', NULL, '3e2912b50d8c6791faadbedae162f53b', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'ready', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 09:50:53.074', NULL, 'c8b9f21f3a69d28376846b3b6c9e6ecd', 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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
|
||||
|
||||
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
|
||||
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
|
||||
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
|
||||
|
||||
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime — so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
|
||||
|
||||
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels — that is this epic.
|
||||
|
||||
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
|
||||
|
||||
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4f70e7d5f578903c7ea33e488e5fa20a', 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 ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: fix concrete catalog gaps (theme-picker missing keys + diff has no catalog)', 'Two confirmed catalog-behind-code gaps found in the June 2026 audit. Small, isolated fix — do this first.
|
||||
|
||||
1) builtin.theme-picker requests two keys that are NOT in lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json, so they silently fall through to the inline placeholder:
|
||||
- section.appearance (lib/builtin/theme_picker/src/settings_view.dart:75)
|
||||
- toggle.highContrast (lib/builtin/theme_picker/src/settings_view.dart:83)
|
||||
Add both keys with their current placeholder text as the translation.
|
||||
|
||||
2) builtin.diff contributes a TabContribution with titleKey ''tab.title'' + i18nNamespace ''builtin.diff'' (lib/builtin/diff/src/extension.dart) but ships NO catalog file. There is no lib/kernel/src/i18n/catalog/builtin.diff_en_us.json. Create it with tab.title (= ''Diff'', matching the placeholder).
|
||||
|
||||
Acceptance: both theme-picker keys resolve from the catalog (not the placeholder); a builtin.diff catalog exists with tab.title; no ''missing key'' i18n warnings on boot for these namespaces.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, '9716086bea4e0317f6be4948f132d633', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.claude UI labels to the catalog (~35 strings)', 'Largest single offender in the audit: ~35 hardcoded user-facing strings in builtin.claude bypass i18n, despite builtin.claude_en_us.json already existing (currently tab.title + 4 orphaned status.* keys).
|
||||
|
||||
Route the hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(key, namespace: ''builtin.claude'', placeholder: <current literal>), and add the keys to the catalog. Hotspots from the audit include lib/builtin/claude/src/prompt_card.dart (e.g. :314 ''Review your answers'', :372 ''Review'') and the permission/AskUserQuestion interaction widgets — sweep the whole extension, not just those lines.
|
||||
|
||||
Watch the interaction-zone widgets (D-78): keep them display-only; the i18n change is label-only, no behavioural change.
|
||||
|
||||
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/claude/ (single-char glyphs like ''<'' / ''+'' used as icons are exempt — note any left); all new keys present in the catalog; existing orphaned status.* keys either wired up or removed.', 'backlog', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, '661934ec63b9365ac504af912d421b5b', 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 ('06FDA0X5WFME83GS0DKYG3WVJ8', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate builtin.welcome UI labels to the catalog (~15 strings)', 'builtin.welcome ships a full 10-key catalog (title, subtitle, open-project, open-project.hint, etc.) that is entirely orphaned — the view hardcodes ~15 strings instead. Wire the existing catalog up and add any missing keys.
|
||||
|
||||
Hotspots from the audit: lib/builtin/welcome/src/welcome_view.dart — e.g. :108 ''TIPS'', :173 ''START'', :258 ''RECENT'', plus the title/subtitle/open-project block the catalog already has translations for.
|
||||
|
||||
Acceptance: no hardcoded user-facing string literals remain in lib/builtin/welcome/; the pre-existing builtin.welcome catalog keys are actually consumed (no orphans); section headers added as keys.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, 'cd2edbe49bfb6af002467e5a7da82abe', 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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate git + diff panel labels to the catalog (~16 strings)', 'builtin.git (~9 hardcoded) and builtin.diff (~7 hardcoded) panel/view labels bypass i18n. builtin.git has a tab.title-only catalog; builtin.diff''s catalog is created by the gaps task (T-463) — extend both.
|
||||
|
||||
Hotspots from the audit:
|
||||
- lib/builtin/git/src/git_status_item.dart:167 ''Switch branch'', :179 ''Loading…''
|
||||
- lib/builtin/git/src/git_panel_view.dart:94 ''Nothing to commit, working tree clean.''
|
||||
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
|
||||
|
||||
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, '1b085e7af9f7720886f23f25a2dc8280', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
|
||||
|
||||
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
|
||||
|
||||
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a5e3b32ca26332d6bf7fbcfb2c52555e', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
|
||||
|
||||
Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) — wire their hardcoded literals up and consume the orphans where they match.
|
||||
|
||||
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
|
||||
|
||||
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'backlog', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'ff32568083c6143d7ea8472c204cbb6e', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: route framework-level labels in lib/widgets + lib/kernel (~10 strings)', '~9 hardcoded user-facing strings in lib/widgets/ and ~1 in lib/kernel/ live outside any extension, so they have no obvious i18n namespace. Resolve the namespace question, then migrate.
|
||||
|
||||
Hotspots from the audit:
|
||||
- lib/widgets/src/ex_line_overlay.dart:163 '':'' , :183 ''Not an editor command'' (these are vim ex-line — may belong to builtin.vim''s namespace rather than a generic one)
|
||||
- lib/widgets/src/multitab_pane.dart:358 ''+'' (likely an icon glyph — exempt if so)
|
||||
|
||||
Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left.
|
||||
|
||||
Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'backlog', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, 'f284696fc306e1e2452f89c5fa8b7057', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 10:06:59.713', NULL, 'f0a3bc1d6ab084ce36fb529cadca2d52', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:07:00.144', NULL, '67beda85d5d212fb1f3493e575a2d338', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:09:41.725', NULL, 'ccd735c9480310dd9a849881c39ce100', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:09:41.911', NULL, 'db4e636e73d5ca1486f9416f47515e03', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:16:22.353', NULL, 'a8d1080773ce726edebb44a0d2d56023', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:16:22.701', NULL, 'e8b9bc4e621f5f39d6bba4904eadf0ac', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Cross-category settings search', 'The search box filters fields across ALL categories; results render as carded groups under category subheaders, editable inline, with per-category match counts shown in the rail. Zero-match categories dim. Wireframe: settings-search.png.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:45', '2026-06-17 10:22:42.544', NULL, 'a10a68df4d4cf7ed0f639d74dda41d37', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-17 10:29:07.736', NULL, 'a9ef8003e03def2e2ba4c1f59fbba07f', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-17 10:31:23.935', NULL, 'a7ddd9362097e6a2f7df764faf7cbf79', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:44:25.018', NULL, 'c5d65c432709c0ffad00237e82b7a23c', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:48:30.719', NULL, '4678d116aadfaea65526200caf8e0ece', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 10:56:30.288', NULL, 'e7d925e921ee3c791398c6b15ebc00f5', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 11:04:11.562', NULL, 'e3bf96df622cc32d69465ac3ec06d269', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-17 11:21:19.581', NULL, '70ec508d70d32c037cbe69b24a280f52', 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 ('06FD15TY946689VZK3MNABSMDW', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Extensions settings category', 'List installed extensions and surface each extension''s registered settings (ext.<id>.* — the extension-owned config pattern from ui-design theme.md); per-extension enable/disable + configure. Reuses the field renderer; extensions register their schema the same way core subsystems do.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:41', '2026-06-17 11:43:37.614', NULL, '09f569e8ad465363d95821413ff7d027', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:08:37.084', NULL, 'e7198c66868b8d602168083dcd40dda9', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:09:28.013', NULL, '8c8ad599dcbe3b500f710b5b03b0c1fb', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:11:21.874', NULL, 'aa269d123343566d0025e9eee2662ab4', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude defaults: ''apply to current sessions?'' prompt on change', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 12:21:23.411', '2026-06-17 12:21:23.411', NULL, '924eef1841e09a2a49c1c39d9bfc1331', 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 ('06FDB1VFADV4QK1YG29T658T8C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude defaults: ''apply to current sessions?'' prompt on change', 'Follow-up to T-457. The Claude settings category sets new-session defaults (model/effort/permission) via generic schema selects — those persist + seed NEW sessions but don''t touch running ones. Add the ''Claude settings changes detected — apply to current sessions?'' prompt: on a default change with ≥1 running session, confirm via DialogRouter, then apply to all sessions through the existing builtin.claude/command channel (/model, /effort, /permissions). Needs a custom control (the generic select can''t prompt), so swap the Claude category''s selects for a custom-control widget per D-? / the T-452 escape hatch.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 12:21:23.411', '2026-06-17 12:21:31.456', NULL, '61f297cc5f01d6f6e43e1fa64efd4875', 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 ('06FD15W7SBVAD3QR5NVE67PKWR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Claude settings category — mirror the sidebar Config panel', 'Mirror lib/builtin/claude/src/meta_sidebar/config_tab.dart in the settings modal: SETTINGS controls (model / effort / permission mode / output style via SettingControlRow publishing the slash command on the live session, D-6 / T-414) plus carded config lists (Skills / Agents / Commands / Hooks / Permissions / MCP Servers) that open their .md (T-183). Reuse the data the sidebar already loads (ClaudeConfig). Wireframe: docs/design/wireframes/settings/settings-claude.png.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 13:20:52', '2026-06-17 12:21:39.311', NULL, '2f3f27095398a0a0e0240b2e0eac34c2', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
|
||||
|
||||
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
|
||||
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
|
||||
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
|
||||
- Each option renders its label **in its own face** as an inline preview.
|
||||
|
||||
**Bake in Inter as the default UI font.**
|
||||
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
|
||||
|
||||
**Font registry + wiring (the real work):**
|
||||
- A small curated registry of bundled faces per role (UI: Inter + 1–2 others; mono: JetBrainsMono + 1–2 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
|
||||
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
|
||||
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
|
||||
|
||||
**Acceptance:**
|
||||
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
|
||||
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
|
||||
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
|
||||
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
|
||||
|
||||
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
|
||||
|
||||
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 18:04:55.886', NULL, '1a2523c3f827cd90a373427f2895a87c', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:11:42.049', NULL, '77cf08e7fff9bed103494aeeed8c7185', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:11:49.697', NULL, '2e4cf96e04c1be6ce99cac33c4350d3c', 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 ('06FD91A7VEW3VCY7QX1END2Z3G', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance: UI + monospace font pickers (bundle Inter as the default UI font)', 'Add two font pickers to the **Appearance** tab of the new settings UI (renders alongside the theme picker, T-452; uses the schema field renderer T-448 and the sectioned-card layout from T-302 / ui-design `surface.md`). Part of the builtin.settings-ui epic (T-444).
|
||||
|
||||
**Two controls (enum/select fields in the Appearance "TYPOGRAPHY" card):**
|
||||
- **UI font** — the proportional/sans face used for app chrome + prose (today `clideUiFamily`, currently JosefinSans Light 300 via `DefaultTextStyle`).
|
||||
- **Monospace font** — the code/paths/IDs/terminal/editor face (today `clideMonoFamily` = JetBrainsMono).
|
||||
- Each option renders its label **in its own face** as an inline preview.
|
||||
|
||||
**Bake in Inter as the default UI font.**
|
||||
- Bundle **Inter** (https://fonts.google.com/specimen/Inter — SIL Open Font License 1.1, Rasmus Andersson) under `assets/fonts/inter/`, register it in `pubspec.yaml` `fonts:`, and add the `assets/licenses.yaml` entry in the SAME commit (D-42 two-step rule). Make Inter the default UI font, replacing JosefinSans Light as the shipped default. Keep **JetBrainsMono** as the default mono.
|
||||
|
||||
**Font registry + wiring (the real work):**
|
||||
- A small curated registry of bundled faces per role (UI: Inter + 1–2 others; mono: JetBrainsMono + 1–2 others) so the pickers have real options without a system-font crawl (v1 = bundled-only; system-font enumeration can be a follow-up).
|
||||
- `lib/widgets/src/typography.dart` `clideUiFamily` / `clideMonoFamily` are constants today — make them resolve from the active setting (and update `root_shell.dart`''s `DefaultTextStyle` + every `fontFamily: clideMonoFamily` consumer: editor, terminal, code blocks, markdown, path/ID text). The terminal (`xterm.dart` fork) takes its own font config — route the mono setting there too.
|
||||
- Persist as settings keys (e.g. `appearance.font.ui`, `appearance.font.mono`) through the SettingsStore schema the renderer reads; scope via the standard model (Always/global default, project override allowed) — see T-449.
|
||||
|
||||
**Acceptance:**
|
||||
- Inter ships and is the default UI font on a fresh install; JetBrainsMono remains default mono. licenses.yaml documents Inter (+ any other added faces).
|
||||
- UI + mono pickers in the Appearance tab change the live app font and persist across restart, at the chosen scope.
|
||||
- Each picker previews options in-face; unknown/removed font falls back gracefully to the default.
|
||||
- No contrast/a11y regression (re-run the contrast gate; faces are metric-different but token colours unchanged).
|
||||
|
||||
**Files:** `pubspec.yaml` (fonts:), `assets/fonts/inter/`, `assets/licenses.yaml`, `lib/widgets/src/typography.dart`, `lib/src/shell/root_shell.dart` (DefaultTextStyle), mono consumers (editor / terminal / markdown / code-block), the Appearance settings category (T-452) + schema registration.
|
||||
|
||||
**Related:** T-444 (epic), T-452 (Appearance category — where these render), T-448 (field renderer), T-449 (scope), ui-design `theme.md` (typography section: clideUiFamily / clideMonoFamily / clideFont* sizes), D-42 (bundled-dep documentation), D-44 (theming).', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 07:39:25.019', '2026-06-17 18:14:38.272', NULL, '138f95c597eebc2db37c9e0c4bc49c91', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:18:13.607', NULL, 'c401f1c31a8bd9f8cb252f718696d07e', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '267cb357e1a5532db0a4bb0f9312ccf2', 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 ('06FDDJ17C3GZWNE98NRVXP189C', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings → Appearance: monospace font picker (needs context-driven mono family)', 'Follow-up to T-460. T-460 shipped the UI-font picker (live via the root DefaultTextStyle, single seam) + bundled Inter as default. The MONOSPACE picker is deferred because clideMonoFamily is referenced as a const in ~38 sites (terminal, diff, code blocks, ClideText fontFamily) — not inherited. To make the mono picker apply live, route the mono family through context (a small FontScope InheritedWidget or the theme) read by those sites, then add a ''Monospace font'' select in Appearance writing app.mono.font (kMonoFontSettingKey already defined in typography.dart). Default JetBrains Mono.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 18:11:42.049', '2026-06-17 18:59:09.679', NULL, 'e81367cebfa243d563325587539f0a3c', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '0a090c8cf04b03de3b82f2e6ea3029de', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '273598190d1cafe66e693899852916e7', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 19:03:19.250', NULL, 'fb570bedf09fd60eb835f107310766d4', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:03:21.095', NULL, 'b9144ecc303e9223fe212b17ee79c9d8', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:03:22.235', NULL, '16d6229039400bd826a77a08eca9573b', 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 19:35:22.690', NULL, '7dd6dae4197fc95d0bc6becfad4eecbb', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:37:39.813', NULL, 'fc64918991f0c9133419ee51227e1939', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:47:02.941', NULL, '8ea58b6162c81778a610f46b07585b05', 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);
|
||||
|
||||
@@ -16,6 +16,84 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.7.0] — 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Settings panel shell.** A new `settings.open` command (⌘`,`, plus a File
|
||||
menu and command-palette entry) opens a centered Settings modal over the
|
||||
dimmed app — the foundation of the schema-driven settings UI. It frames the
|
||||
category rail and scrolling carded panel that later work fills in; closes on
|
||||
✕, Esc, or a barrier tap. (T-445)
|
||||
|
||||
- **Schema-driven settings engine.** Subsystems register a `SettingsCategory`
|
||||
(via `SettingsCategoryContribution` → the kernel `SettingsRegistry`); the
|
||||
panel renders it as carded sections of toggle/select/text/number/file rows,
|
||||
each bound to a `SettingsStore` key with help and reset-to-default.
|
||||
Registering a category surfaces a new tab. (T-448)
|
||||
|
||||
- **Settings category rail.** The modal's left rail lists the registered
|
||||
categories (icon + title, data-driven from the registry) with an accent
|
||||
left-stripe + surfaceHi selection; picking one swaps the panel. (T-447)
|
||||
|
||||
- **Per-field scope tags.** Each field shows where its value lives — folder =
|
||||
Project (`.clide`), globe = Always (`~/.clide`), circle-dashed =
|
||||
Default/unset — with a tooltip and a menu to move the value between scopes or
|
||||
reset it. Backed by scope-explicit `SettingsStore` access. (T-449)
|
||||
|
||||
- **Cross-category settings search.** A search box atop the rail filters fields
|
||||
across every category; the panel shows the matches grouped under category
|
||||
subheaders (editable inline), and each rail row shows its match count with
|
||||
zero-match categories dimmed. (T-450)
|
||||
|
||||
- **Settings → Activity category.** The first real settings tab: the
|
||||
conversation fold level (none / tools / thinking / everything) as a schema
|
||||
field; picking a level applies live to the activity stream. (T-453)
|
||||
|
||||
- **Settings → Keymap category.** A preset select (Default / Vim / VS Code /
|
||||
JetBrains); picking one switches the active keymap live via the preset
|
||||
command. (T-451)
|
||||
|
||||
- **Settings → Appearance category.** A theme picker in the panel — base-theme
|
||||
chips + a high-contrast toggle, applied live. Adds the engine's custom-control
|
||||
escape hatch (`SettingsControlContribution` / `SettingsControlRegistry`) for
|
||||
one-off controls the generic field kinds can't express. (T-452)
|
||||
|
||||
- **Settings → Extensions tab.** A "watch this space" notice — installing and
|
||||
toggling extensions arrives with third-party (Lua) support; built-ins stay
|
||||
always-on for now. (T-456)
|
||||
|
||||
- **Settings → Claude category.** New-session defaults — model, effort, and
|
||||
permission mode — seed fresh sessions (effort via `--effort` at spawn;
|
||||
model and permission applied right after start). (T-457)
|
||||
|
||||
- **Inter is the default UI font + a UI-font picker.** Bundled Inter (variable)
|
||||
as the default interface typeface — Josefin Sans stays selectable — and
|
||||
Settings → Appearance gains a UI-font picker that applies live. (T-460)
|
||||
|
||||
- **Monospace font picker.** Settings → Appearance adds a monospace font select
|
||||
(JetBrains Mono / Fira Mono) that applies live to the terminal, diffs, code,
|
||||
and IDs. Bundles Fira Mono. (T-471)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
||||
changed from "Settings…" to "Theme…" so it no longer collides with the new
|
||||
Settings panel in the palette; behaviour is unchanged. (T-445)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Monospace setting now applies everywhere.** Eleven context-free render
|
||||
helpers (Claude tool bodies + results, inline markdown code/refs, search
|
||||
previews, welcome tips) hard-coded JetBrains Mono and ignored the Monospace
|
||||
font setting; they now honour it live. (T-472)
|
||||
|
||||
## [2.6.0] — 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -89,6 +89,8 @@ Shell hygiene (keeps commands inside the permission allowlist, so they don't get
|
||||
|
||||
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
||||
|
||||
**Never `git add -A` or `git add .` — stage explicit paths every time (`git add <file> …`), no exceptions.** This worktree can host concurrent Claude sessions: a blanket add vacuums another session's in-progress files — and your own unrelated edits — into your commit, mislabeling work and entangling history (this has happened). If `git status` shows files you didn't touch this turn, they are not yours to stage. **Always create commits through the [`git-commit` skill](.claude/skills/git-commit/SKILL.md)** — it encodes the message format (Conventional Commits, per [D-37](governance/decisions/process.md#d-37)), the explicit-staging rule, changelog discipline, and the safety reminders. Don't hand-roll a commit that skips it.
|
||||
|
||||
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
|
||||
|
||||
## Changelog discipline
|
||||
|
||||
+8
-4
@@ -181,14 +181,18 @@ tickets before the diff lands. Trivial typo fixes don't need one.
|
||||
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||
|
||||
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
||||
(this isn't a Conventional Commits repo — the archived Python
|
||||
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
||||
- [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||
`type(scope): imperative subject`, ≤ 72 chars including the prefix.
|
||||
Types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`,
|
||||
`test`, `build`, `chore`; scope is the subsystem (`settings`,
|
||||
`vim`, `pty`…); keep a trailing `(T-NNN)` ticket ref where one
|
||||
applies — e.g. `feat(settings): category rail + navigation (T-447)`.
|
||||
- One logical change per commit. If the subject needs "and", split it.
|
||||
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||
Removed, Fixed, Security). Keep entries to one or two short
|
||||
sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`.
|
||||
sentences — the 60-word cap is enforced by the pre-push gate
|
||||
(`make changelog-gate`).
|
||||
- Co-author trailer:
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||
any of the diff.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document governs what clide is allowed to do at runtime, what it's allowed to depend on, and how contributors — human and agent — introduce code into the project. It is binding on all contributors. When in doubt, stop and ask.
|
||||
|
||||
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `decisions/`. This document sets the rules; `decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
||||
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `governance/decisions/`. This document sets the rules; `governance/decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
||||
|
||||
## Why this document exists
|
||||
|
||||
@@ -124,9 +124,9 @@ When removing a dependency:
|
||||
|
||||
1. **Grep the entire repository** for references to the package, its exports, and any type names it contributed. `rg '<package>|<PackageType>|<prefix_>'` across the repo. Zero hits outside git history is the goal. A single lingering import will break the build; a single lingering FFI stub or type alias will compile fine and fail at runtime.
|
||||
2. **Regenerate the lockfile** as part of the same PR. A `pubspec.yaml` with the dep removed but a `pubspec.lock` that still pins it is a partial removal, and CI or a fresh clone will happily continue installing the package.
|
||||
3. **Update `app/assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
||||
3. **Update `assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
||||
4. **Remove any vendored artifacts** tied to the dep — binaries, prebuilt assets, generated bindings — and delete their `BUILD.md` records. An orphaned vendored binary is worse than a removed one because it looks legitimate.
|
||||
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `decisions/`) needs updating.
|
||||
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `governance/decisions/`) needs updating.
|
||||
|
||||
A dependency is not removed until all five are true. "I deleted the line from pubspec.yaml" is the start of the removal, not the end.
|
||||
|
||||
@@ -188,18 +188,18 @@ When in doubt about a license, the dependency does not land until the question i
|
||||
|
||||
### Attribution requirements
|
||||
|
||||
- The license manifest at `app/assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
||||
- The license manifest at `assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
||||
- Transitive dependencies are listed, not just direct ones. If `wasm_run` pulls in `wasmtime` which pulls in `cranelift`, all three appear.
|
||||
- Apache-2.0 dependencies get their `NOTICE` file content preserved verbatim, not summarized.
|
||||
- Apache-2.0-with-LLVM-exception (e.g., Cranelift, parts of LLVM) requires the LLVM exception text specifically, not just the Apache-2.0 boilerplate.
|
||||
- Fonts and icon sets get attributed even if the license doesn't strictly require it. It's the right thing to do.
|
||||
- `app/assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
||||
- `assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
||||
|
||||
Adding a dependency means updating the license manifest in the same PR. No exceptions.
|
||||
|
||||
## Changelog and commit conventions
|
||||
|
||||
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
||||
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages (see [D-37](governance/decisions/process.md#d-37)). Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
||||
|
||||
Security-relevant changes — CVE responses, dependency-driven vulnerability fixes, the removal of a phoning-home transitive dep, anything where the rules in this document were the reason for the change — go under the `Security` heading of the release's changelog entry, regardless of whether the code change itself looks security-shaped. That heading is the trail future-us follows to reconstruct why a dep was bumped or removed. Lumping security fixes under `Fixed` because the diff looks like a normal bug fix loses that signal and is the wrong choice even when it's technically accurate.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-po
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence (D-41).
|
||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the tmux-backed D-41).
|
||||
|
||||
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
||||
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
||||
@@ -15,7 +15,7 @@ Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI su
|
||||
|
||||
## Built-in extensions
|
||||
|
||||
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
|
||||
canvas, claude, claude_control, cli_install, decisions, deeplink, default_layout, diff, editor, extensions_ui, files, git, grammars_core, graph, ipc_status, keybindings_ui, markdown, menubar, output, pql, problems, search, settings_ui, terminal, theme_picker, tickets, todos, view, vim, welcome.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -44,7 +44,7 @@ make push-check # pre-push gate: decisions + core + fast + a11y + coverage
|
||||
|
||||
## Status
|
||||
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
Active development; the interaction model, panel system, and settings engine have landed. The Python Textual predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+26
-4
@@ -39,7 +39,7 @@ self:
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.5.0"
|
||||
version: "2.7.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
@@ -54,10 +54,32 @@ dependencies:
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/jetbrains_mono/OFL.txt
|
||||
purpose: >-
|
||||
Monospace face for terminal panes, diff views, code editors, and
|
||||
Default monospace face for terminal panes, diff views, code editors, and
|
||||
any other monospace surface.
|
||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||
|
||||
- name: Fira Mono
|
||||
kind: font
|
||||
version: "3.206"
|
||||
homepage: https://github.com/mozilla/Fira
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/fira_mono/OFL.txt
|
||||
purpose: >-
|
||||
Selectable monospace face (Settings → Appearance, T-471); JetBrains Mono
|
||||
remains the default.
|
||||
weights_bundled: [Regular, Bold]
|
||||
|
||||
- name: Inter
|
||||
kind: font
|
||||
version: "variable"
|
||||
homepage: https://github.com/rsms/inter
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/inter/OFL.txt
|
||||
purpose: >-
|
||||
Default application UI face (T-460). Variable font with optical-size
|
||||
and weight axes; Josefin Sans remains bundled as a selectable option.
|
||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||
|
||||
- name: Josefin Sans
|
||||
kind: font
|
||||
version: "variable"
|
||||
@@ -65,8 +87,8 @@ dependencies:
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/josefin_sans/OFL.txt
|
||||
purpose: >-
|
||||
Application UI face. Default weight Light (300); full 100-700
|
||||
range available via the variable-font weight axis.
|
||||
Selectable application UI face (was the default before T-460). Default
|
||||
weight Light (300); full 100-700 range via the variable-font weight axis.
|
||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||
|
||||
- name: Phosphor Icons
|
||||
|
||||
-776
@@ -1,776 +0,0 @@
|
||||
# clide — External Consultant Review
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Scope:** Full-repository assessment of clide at `main` (commit `9030e56`).
|
||||
**Method:** Six independent specialist reviewers, each given read-only access and a
|
||||
brief covering best practice, clean code, architecture, usability, stability,
|
||||
expandability, style, consistency, and general quality. Reviewers did not see each
|
||||
other's findings; cross-cutting themes below are genuine independent agreement.
|
||||
|
||||
**Panel:**
|
||||
| Lens | Reviewer |
|
||||
|---|---|
|
||||
| Architecture | Software Architect |
|
||||
| Tests & quality gates | Test / QA Analyst |
|
||||
| UX & accessibility | UX & Accessibility Expert |
|
||||
| Code quality & craft | Senior Dart/Flutter Engineer |
|
||||
| Security & supply chain | Security Engineer |
|
||||
| Docs, governance & DX | TPM / Developer-Experience Consultant |
|
||||
|
||||
---
|
||||
|
||||
## Overall verdict
|
||||
|
||||
clide is, for a solo-dev pre-v2.0 project, **unusually disciplined** — every reviewer
|
||||
said so independently. The governance system is alive, the core subsystems are small
|
||||
and well-typed, the FFI/PTY layer shows real systems-programming care, and the quality
|
||||
gates are genuine rather than ornamental. The codebase is in good shape.
|
||||
|
||||
The weaknesses cluster into a handful of themes, and several are **load-bearing**: a
|
||||
central guardrail (CLI-first IPC) has no runtime implementation, keyboard operability —
|
||||
the core requirement of a power-user dev tool — is largely unbuilt, an untrusted
|
||||
workspace can achieve code execution, and the two primary onboarding documents describe
|
||||
an architecture that no longer exists.
|
||||
|
||||
None of these are fatal; all are fixable; most have quick-win first steps. But they
|
||||
should be addressed before a public v2.0.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting themes (independent agreement)
|
||||
|
||||
These were each flagged by **two or more** reviewers who did not coordinate:
|
||||
|
||||
1. **The IPC layer is mid-migration and contradicts itself.** The Architect found no
|
||||
Unix-socket *server* anywhere in `lib/` — D-56's "app hosts an in-process IPC server"
|
||||
and D-1's "CLI-first, not MCP" have no runtime path; three IPC clients
|
||||
(`DaemonClient`, `InProcessClient`, `IsolateClient`) coexist with two duplicated
|
||||
service-wiring sites. The Security reviewer independently noted `DaemonClient`'s
|
||||
socket code is still live as an unvalidated attack surface. **Pick one IPC model,
|
||||
implement or amend D-56, delete the other two.**
|
||||
|
||||
2. **The "no pre-existing excuse / clean board" guardrail is being violated right now.**
|
||||
`flutter analyze` reports 9 `unnecessary_import` issues in `test/`; `ci/test.sh` runs
|
||||
analyze with `--no-fatal-infos`, which silently tolerates them. Flagged by the
|
||||
Architect, Code Quality, and Test reviewers. The repo's own rules say fix-first.
|
||||
|
||||
3. **`lib/src/terminal/` is in an undeclared middle state.** ~7k LOC forked from
|
||||
xterm.dart, carrying commented-out `print`s, dangling TODOs, a 1137-line `parser.dart`,
|
||||
and the only `invalid_use_of_protected_member` suppression in the repo. MEMORY says
|
||||
"code under `lib/` is owned, not vendored" — so it must either be formally vendored
|
||||
(frozen, documented, decision-recorded) or cleaned to the project bar. Flagged by
|
||||
Code Quality; the Architect's "consistency" deduction points at the same seam.
|
||||
|
||||
4. **Documentation describes a dissolved architecture.** `README.md` and
|
||||
`docs/initial-plan.md` still describe a Go sidecar, `ptyc/` C helper, `app/`
|
||||
subdirectory, and a separate `clide --daemon` process — all removed by D-5, D-56, and
|
||||
the FFI pivot. A new contributor's first read builds a wrong mental model.
|
||||
|
||||
---
|
||||
|
||||
## Consolidated scorecard
|
||||
|
||||
Scores are each reviewer's, 1–5, on their own dimensions.
|
||||
|
||||
| Domain | Dimension | Score |
|
||||
|---|---|---|
|
||||
| **Architecture** | Layering & dependency direction | 4 |
|
||||
| | Separation of concerns | 4 |
|
||||
| | Expandability | 5 |
|
||||
| | Consistency | 4 |
|
||||
| | Guardrail adherence | 3 |
|
||||
| **Tests** | Coverage quality | 4 |
|
||||
| | Test reliability / flakiness | 3 |
|
||||
| | Gate trustworthiness | 3 |
|
||||
| | Test maintainability | 5 |
|
||||
| | Regression-catching power | 4 |
|
||||
| **UX / a11y** | Interaction model | 2 |
|
||||
| | Accessibility | 3 |
|
||||
| | Visual consistency | 4 |
|
||||
| | Discoverability | 2 |
|
||||
| | State coverage (loading/error/empty) | 3 |
|
||||
| **Code quality** | Idiomatic Dart | 4 |
|
||||
| | Error handling | 4 |
|
||||
| | Naming & readability | 4 |
|
||||
| | Consistency across subsystems | 3 |
|
||||
| | Resource / lifecycle safety | 4 |
|
||||
| **Security** | Subprocess safety | 2 |
|
||||
| | IPC input validation | 3 |
|
||||
| | Path / filesystem safety | 3 |
|
||||
| | Dependency / supply-chain hygiene | 3 |
|
||||
| | Secrets & sandboxing | 3 |
|
||||
| **Docs / governance** | Governance discipline | 4 |
|
||||
| | Documentation accuracy | 2 |
|
||||
| | Changelog hygiene | 3 |
|
||||
| | Contributor onboarding | 2 |
|
||||
| | Convention adherence | 4 |
|
||||
|
||||
**Highest marks:** expandability (5), test maintainability (5). The extension contract
|
||||
and test-helper design are genuine standouts.
|
||||
**Lowest marks:** interaction model (2), discoverability (2), subprocess safety (2),
|
||||
documentation accuracy (2), contributor onboarding (2).
|
||||
|
||||
---
|
||||
|
||||
## Prioritized action list
|
||||
|
||||
Synthesized across all six reviews. Severity is the highest any reviewer assigned.
|
||||
|
||||
### Critical — address before public v2.0
|
||||
|
||||
1. **Fix untrusted-workspace code execution.** `toolchain_paths.dart:79` resolves
|
||||
`native/dugite/bin/git` relative to the *workspace root*; a malicious repo can plant
|
||||
an executable there that clide runs on the first auto-fired `git.status`. Resolve
|
||||
`native/dugite` against `Platform.resolvedExecutable`'s directory, never the
|
||||
workspace. *(Security)*
|
||||
2. **Resolve the IPC story.** Either implement the in-process Unix-socket server per
|
||||
D-56 so the `clide` CLI / C client actually works, or amend D-56 to make in-process
|
||||
direct dispatch the design and delete `DaemonClient`'s socket code, `IsolateClient`,
|
||||
`Backend`, and `backend_entry.dart`. Today the code claims three models and runs one,
|
||||
and a load-bearing guardrail (D-1/D-6) is unmet. *(Architecture, Security)*
|
||||
3. **Make the tool keyboard-operable.** `ClideTappable` (base of nearly every
|
||||
interactive widget) is mouse-only — no `Focus`, no Enter/Space. The command palette
|
||||
has no arrow-key navigation and no Escape. For a keyboard-first dev tool this is a
|
||||
functional gap, not a polish item. *(UX)*
|
||||
4. **Fix the onboarding docs.** Rewrite `README.md`'s `ptyc/` / `make ptyc-build`
|
||||
sections, fix its dead `decisions/` link, and banner `docs/initial-plan.md` as
|
||||
historical (or split out a current `docs/architecture.md`). *(Docs)*
|
||||
|
||||
### Major — should land soon
|
||||
|
||||
5. Add symlink re-resolution + containment re-check in `files.read` / `files.ls` — a
|
||||
repo symlink `config -> /etc/shadow` currently passes path-safety. *(Security)*
|
||||
6. Add `test-integration` (and ideally `smoke-bundle`) to `make push-check` — the gate
|
||||
that catches "app won't boot" is currently omitted from the pre-push gate. *(Tests)*
|
||||
7. Schema-validate the IPC argument surface; reject `-`-prefixed `branch`/`remote`/`path`
|
||||
values; add size/count bounds. *(Security)*
|
||||
8. Establish a real focus-traversal model (`FocusTraversalGroup` per slot, a documented
|
||||
"focus next panel" keybinding) and integrate `FocusTracker` with Flutter's focus
|
||||
system instead of paralleling it. *(UX)*
|
||||
9. Fix the `SchedulerService._startTicker` isolate-spawn race — a `_stopTicker()` before
|
||||
the spawn future resolves leaks a forever-ticking isolate. Mirror `NativePty`'s
|
||||
`_readerReady` pattern. *(Code quality)*
|
||||
10. Decide the status of `lib/src/terminal/` — formally vendor (and decision-record) it,
|
||||
or do the cleanup sweep. *(Code quality)*
|
||||
11. Replace fixed wall-clock `Future.delayed` sleeps in `watcher_test.dart` /
|
||||
`session_test.dart` with event-driven waits; make swallowed `onTimeout` callbacks
|
||||
`fail()` loudly. *(Tests)*
|
||||
12. Write a human-facing `CONTRIBUTING.md`; cut an interim release to drain the ~80-commit
|
||||
`[Unreleased]` backlog; merge duplicate changelog subsection headings. *(Docs)*
|
||||
13. Single global `KeyboardListener` → scoped `Shortcuts`/`Actions`; move
|
||||
`KeybindingResolver` off layout-dependent `keyLabel`. *(UX)*
|
||||
|
||||
### Quick wins — hours each
|
||||
|
||||
- Clear the 9 `unnecessary_import` analyzer issues; drop `--no-fatal-infos` from
|
||||
`ci/test.sh`. *(Architecture, Tests, Code quality)*
|
||||
- Run the `forkpty` PTY tests with `--coverage` so `native_pty.dart` — the riskiest file
|
||||
— is honestly measured. *(Tests)*
|
||||
- Add a `Focus` + Enter/Space wrapper and a focus-ring inside `ClideTappable`; this fixes
|
||||
the keyboard gap for every button and list item at once. *(UX)*
|
||||
- Add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern). *(UX)*
|
||||
- Amend D-66 to reflect the coverage floor's real location (`pubspec.yaml`), mechanism,
|
||||
and value (90%) — it currently disagrees with the changelog and the code. *(Docs)*
|
||||
- Reconcile `licenses.yaml` with `pubspec.yaml` (`test` version drift, phantom `lints`
|
||||
entry); add a `native/SHA256SUMS` manifest. *(Security, Docs)*
|
||||
- Replace silent `catch (_)` in `tree_sitter_ffi.dart` with a logged last-error.
|
||||
*(Code quality)*
|
||||
- Fix the `clide.dart` barrel leak in `file_tree_view.dart:8`; narrow the barrel (drop
|
||||
the `dispatcher.dart` export); move `test_app.dart` out of the production `main.dart`
|
||||
import graph. *(Architecture)*
|
||||
- Expand the contrast gate's `canonicalPairs` to cover `globalTextMuted`, the `status*`
|
||||
colors, and `panelActiveBorder`. *(UX)*
|
||||
- Triage stale governance Q-records (Q-1/2/3/25 overtaken by shipped Tier-1 work).
|
||||
*(Docs)*
|
||||
|
||||
---
|
||||
|
||||
# Full reviews
|
||||
|
||||
## 1. Architecture — Software Architect
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is an unusually disciplined solo-dev codebase. The governance system (67
|
||||
D-records, tracked Q/R) is real and largely honored in code, the kernel/extension split
|
||||
is coherent, and the feature-first layout with barrel files is consistently applied. The
|
||||
single biggest strength is the **extension contract**: every built-in — including layout
|
||||
itself — passes the same `ClideExtension` + `ContributionPoint` contract, which is the
|
||||
best possible proof the contract is usable. The single biggest risk is **architectural
|
||||
drift in the IPC layer**: D-56 mandates the Flutter app host an in-process IPC server
|
||||
reachable by a thin C client over a unix socket, but no socket server exists anywhere in
|
||||
`lib/` — the "CLI-first, not MCP" guardrail (D-1) has no runtime path today. Compounding
|
||||
this, three parallel IPC client implementations (`DaemonClient` socket,
|
||||
`InProcessClient`, `IsolateClient` + `Backend`) coexist with two competing
|
||||
service-wiring sites (`main.dart` and `backend_entry.dart`), suggesting an unfinished
|
||||
migration.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Extension contract is clean and scales** — `lib/extension/src/extension.dart` +
|
||||
`contribution.dart`: sealed `ContributionPoint` hierarchy, `ClideExtensionContext`
|
||||
lists services explicitly (deliberately avoiding a `KernelServices` import cycle —
|
||||
`extension.dart:50-52`). `ExtensionManager` does dependency topo-sort,
|
||||
dependency-gated activation, and contribution apply/remove symmetrically
|
||||
(`extensions_manager.dart:164-202`). Adding a pane = new extension file + one
|
||||
`register()` line in `main.dart`.
|
||||
- **Kernel admission rule is enforced, not aspirational** — D-12's "mandatory shared
|
||||
singleton" test visibly shaped `KernelServices` (`facade.dart:38-93`); ~25 services,
|
||||
each defensibly cross-cutting. The two-tier disable model (D-14) is honored:
|
||||
`default_layout` is itself an extension.
|
||||
- **Feature-first layout with barrel discipline** (D-8) is consistent — every
|
||||
`builtin/<name>/` and `kernel/` has a barrel; builtins import
|
||||
`package:clide/kernel/kernel.dart`, not deep paths. Only one leak found.
|
||||
- **Governance-to-code traceability is genuine** — `WidgetsApp` root (D-7) at
|
||||
`app.dart:38`, `ChangeNotifier`/`ListenableBuilder` state (D-10) everywhere, git
|
||||
hardcoded in toolchain/project loader (D-13), terminal correctly tagged
|
||||
`inlined-source` in `licenses.yaml` with modifications documented.
|
||||
- **Git subsystem cohesion** — `lib/src/git/` cleanly split into `client` / `status` /
|
||||
`diff` / `operations` (~250 lines each), each a single responsibility.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] No IPC socket server exists** — D-56 specifies the app hosts an
|
||||
in-process IPC server with a C client connecting over a unix socket. `grep` for
|
||||
`ServerSocket`/unix-domain `bind` in `lib/` returns nothing. `DaemonClient._connect`
|
||||
(`client.dart:72-94`) *connects* to a socket, but nothing *serves* one. Today the only
|
||||
working path is `InProcessClient` (`in_process.dart`), which calls the dispatcher
|
||||
directly in-process. **Claude cannot drive clide via `clide ...` — the CLI-first
|
||||
guardrail (D-1, D-6) has no implementation.** This is the load-bearing contract of the
|
||||
whole project and it is absent.
|
||||
- **[Major] Three IPC clients + two wiring sites = unfinished migration** —
|
||||
`DaemonClient` (socket), `InProcessClient`, and `IsolateClient`+`Backend`/
|
||||
`backend_entry.dart` all coexist. `main.dart:76-113` wires subsystems via
|
||||
`buildDispatcher`; `backend_entry.dart:40-110` wires the *same* five subsystems again
|
||||
inside an isolate. `Backend.spawn` is referenced only by `facade.dart` but `main.dart`
|
||||
uses `autoStartDaemonClient: false` + `daemonClientFactory` (the in-process path).
|
||||
Dead-or-dormant isolate infrastructure with duplicated registration logic — pick one
|
||||
and delete the others.
|
||||
- **[Major] `main.dart` (production entry) imports `test_app.dart`** — `main.dart:2` and
|
||||
`:51-55`. The production binary carries the test harness and branches on
|
||||
`CLIDE_TESTMODE`. Test scaffolding should not be reachable from the shipping entry
|
||||
point; gate it behind a separate entrypoint or `kDebugMode`.
|
||||
- **[Minor] `flutter analyze` reports 9 issues** — all `unnecessary_import` in `test/`,
|
||||
but CLAUDE.md's "no pre-existing excuse" / "clean board" guardrail makes this a
|
||||
fix-first item.
|
||||
- **[Minor] Barrel leak** — `lib/builtin/files/src/file_tree_view.dart:8` imports
|
||||
`package:clide/src/files/listing.dart` directly instead of via
|
||||
`package:clide/clide.dart` (which already re-exports `FileEntry`).
|
||||
- **[Minor] `clide.dart` barrel exports the daemon dispatcher** — `clide.dart:15`
|
||||
exports `src/daemon/dispatcher.dart`. The barrel is described as "shared types"; the
|
||||
dispatcher is server-side machinery.
|
||||
- **[Minor] `ExtensionManager.activate` swallows exceptions** (`extensions_manager.dart:
|
||||
141-143`) — a failed `activate()` logs and continues, leaving the extension
|
||||
un-activated but `_known`, with no surfaced "degraded" state for the UI.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** clear the 9 analyzer issues; fix the `file_tree_view.dart` barrel leak
|
||||
(consider a CI grep gate for `package:clide/src/` imports outside their feature); move
|
||||
`test_app.dart` out of the production import graph; drop the `dispatcher.dart` export
|
||||
from `clide.dart`.
|
||||
|
||||
**Larger efforts:** resolve the IPC story (implement the socket server per D-56, or
|
||||
amend D-56 and delete `DaemonClient`/`IsolateClient`/`Backend`/`backend_entry.dart`);
|
||||
collapse subsystem wiring into one `registerAllSubsystems(...)` function; give
|
||||
`ExtensionManager` a surfaced failure state so the UI can show degraded built-ins.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Layering & dependency direction | 4/5 | Kernel→extension direction clean, context-vs-aggregate split avoids cycles; docked for the `src/`↔`kernel/src/` barrel leak and the dispatcher export. |
|
||||
| Separation of concerns | 4/5 | Feature-first layout, single-responsibility subsystems; duplicated subsystem registration is the blemish. |
|
||||
| Expandability | 5/5 | New pane = one extension file + one `register()` line; sealed contribution hierarchy; layout itself is data and extension-shaped. |
|
||||
| Consistency | 4/5 | Barrels, naming, D-record back-references uniform; three coexisting IPC clients and 9 analyzer issues break the bar. |
|
||||
| Guardrail adherence | 3/5 | `WidgetsApp`, single-process, no-Material, governance, zero-deps all honored — but D-1/D-6/D-56 (CLI-first via socket server) have no runtime implementation. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tests & quality gates — Test / QA Analyst
|
||||
|
||||
### Executive summary
|
||||
|
||||
The clide test suite is, for a solo-dev pre-2.0 project, in genuinely good shape. ~104
|
||||
test files against 276 lib files, ~92.75% line coverage, and — critically — the coverage
|
||||
was *not* bought with assertion-free filler. Even the alarmingly-named files
|
||||
(`coverage_trivials_test.dart`, `zero_coverage_widgets_test.dart`,
|
||||
`services_stubs_test.dart`, `mop_up_test.dart`) contain real behavioral assertions. The
|
||||
biggest strength is a sensibly layered pyramid with a real boot-path integration gate
|
||||
and a startup smoke test that catches the "tests pass but app won't launch" class. The
|
||||
biggest risk is **flakiness from wall-clock-dependent tests** — fixed `Future.delayed`
|
||||
sleeps in file-watcher and PTY tests will eventually produce intermittent CI failures,
|
||||
and the PTY tests are run via `dart test` so they are **excluded from the coverage
|
||||
measurement entirely**.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Test pyramid is sound.** Pure-Dart unit, widget tests with a shared harness, golden
|
||||
tests (Alchemist), an a11y contract layer, and 3 real-boot `integration_test/` files —
|
||||
correctly separated by runner (`ci/test.sh` vs `ci/test_core.sh` vs
|
||||
`ci/test_integration.sh`).
|
||||
- **Helpers are well-designed.** `test/helpers/kernel_fixture.dart` boots a real
|
||||
`KernelServices` with in-memory themes/i18n and `autoStartDaemonClient: false` — no
|
||||
real socket, temp-dir scoped, proper `dispose()`. `FakeDaemonClient` is a clean stub.
|
||||
- **Error-branch discipline.** `pql_commands_errors_test.dart` /
|
||||
`git_commands_errors_test.dart` deliberately point the toolchain at a non-existent
|
||||
binary to drive catch-branches the happy path can't reach — table-driven, with
|
||||
`reason:` tags.
|
||||
- **OS-dialog avoidance is handled correctly.** `welcome/dialog_test.dart` mocks the
|
||||
`clide/window` MethodChannel to throw `MissingPluginException`, exercising the fallback
|
||||
path *without* spawning a native file picker.
|
||||
- **Startup gate.** `ci/smoke_bundle.sh` builds the real release bundle and runs it
|
||||
under xvfb for 5s, correctly interpreting `timeout` exit codes (124/143 = healthy).
|
||||
- **Coverage gate is honest.** `ci/coverage_gate.sh` is a self-contained awk parser (no
|
||||
`lcov` dependency), ratchets only upward, and `exit 2` distinguishes "missing data"
|
||||
from "below floor."
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] PTY tests are excluded from coverage.** `ci/test.sh:13` runs
|
||||
`flutter test --coverage --exclude-tags forkpty`; the `forkpty` tests run separately
|
||||
via `dart test` with no `--coverage`. So `lib/src/pty/native_pty.dart` — the
|
||||
highest-risk native code in the repo — is barely in the measured denominator. The
|
||||
92.75% number overstates coverage of the riskiest file.
|
||||
- **[Major] Wall-clock sleeps will flake.** `test/files/watcher_test.dart:67-82` uses
|
||||
fixed `Future.delayed`; `test/pty/session_test.dart:71` polls 50×100ms and `:65` uses a
|
||||
bare `500ms` settle. `session_test.dart`'s `timeout(5s, onTimeout: () {})` (`:48`)
|
||||
*swallows* the timeout — a never-producing PTY proceeds to a confusing assertion
|
||||
failure rather than a clear timeout.
|
||||
- **[Major] `make push-check` does not run integration tests.** `push-check:
|
||||
decisions-validate test-core test test-a11y coverage-gate` — `test-integration` and
|
||||
`smoke-bundle` are omitted. A boot-order regression sails through.
|
||||
- **[Minor] `flutter analyze --no-fatal-infos` in `ci/test.sh:9`** contradicts the
|
||||
stated "fail-on-warning, clean board" discipline.
|
||||
- **[Minor] Integration tests run one-file-at-a-time** to dodge a batch "Unable to start
|
||||
the app" error — each invocation re-boots the engine (slow), and the workaround masks
|
||||
whether the batch failure is environmental or a real teardown leak.
|
||||
- **[Minor] Golden CI config disabled.** Only platform goldens run; a Linux-only CI
|
||||
never validates the macOS goldens, and stale `test/goldens/failures/*.png` artifacts
|
||||
are committed to the repo.
|
||||
- **[Minor] `test_core.sh` timeout kill is best-effort** — the `pkill -9 -f` pattern
|
||||
match is redundant noise next to the real `setsid` + `timeout --kill-after` safety net.
|
||||
- **[Minor] `git/client_test.dart` depends on the ambient `git` binary**, not the
|
||||
vendored dugite — the suite passes/fails on the host git version.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add `test-integration` (and `smoke-bundle`) to `push-check` — the single
|
||||
highest-value change; run the `forkpty` tests with `--coverage`; drop `--no-fatal-infos`;
|
||||
gitignore `test/goldens/failures/`; make `onTimeout` callbacks `fail()`.
|
||||
|
||||
**Larger efforts:** replace fixed sleeps with event-driven waits
|
||||
(`expectLater(stream, emits(...))`); add a macOS golden CI matrix entry or document
|
||||
goldens as advisory; consider a coverage-exclusion allowlist for genuinely-unreachable
|
||||
defensive branches rather than chasing the last lines with filler tests.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Coverage quality | 4/5 | Tests are meaningful even in "mop-up" files; docked because PTY/FFI is outside the measured number. |
|
||||
| Test reliability / flakiness | 3/5 | Fixed wall-clock sleeps and a swallowed timeout are latent intermittent failures. |
|
||||
| Gate trustworthiness | 3/5 | Coverage gate and smoke bundle are well-built, but `push-check` omits integration tests. |
|
||||
| Test maintainability | 5/5 | Shared fixtures, consistent structure, table-driven error suites, clear doc comments. |
|
||||
| Regression-catching power | 4/5 | Real boot-path integration + smoke + a11y + goldens; weakened by single-OS goldens and PTY coverage gaps. |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX & accessibility — UX & Accessibility Expert
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide has an unusually disciplined *foundation* for a solo pre-v2.0 project: a coherent
|
||||
semantic design-token system, a WCAG-AA contrast gate wired into CI, and i18n/semantic
|
||||
contract tests. That foundation is the biggest strength. The biggest risk is that
|
||||
**keyboard operability is largely unimplemented below the foundation** — the project's
|
||||
own core interaction primitive (`ClideTappable`) is mouse-only, the command palette has
|
||||
no arrow-key navigation or Escape, and there is no focus-traversal wiring across panels.
|
||||
For a keyboard-first power-user dev tool, this is a critical gap that the a11y test suite
|
||||
does not catch because the tests assert *structural* presence (Semantics nodes exist)
|
||||
rather than *operability* (can you actually drive it from the keyboard).
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Semantic token system is real and enforced.** `lib/kernel/src/theme/tokens.dart`
|
||||
defines ~65 named surface tokens; widgets consume `ClideTheme.of(context).surface`
|
||||
rather than raw colors. The resolver provides defaults so partial themes still produce
|
||||
a complete `SurfaceTokens`.
|
||||
- **Contrast gate is genuine WCAG math, run per-theme.** `lib/kernel/src/theme/
|
||||
contrast.dart` implements real relative-luminance ratio with alpha pre-compositing
|
||||
against neutral grey (`contrast.dart:31-37`) — semi-transparent tokens can't spuriously
|
||||
pass.
|
||||
- **Semantics are present on composed widgets.** `ClideButton` wraps
|
||||
`Semantics(button: true, enabled:, label:, hint:, onTap:)`; panels set
|
||||
`container: true, explicitChildNodes: true` with landmark labels.
|
||||
- **State coverage exists in data panels.** `git_panel_view.dart:86-104` handles error,
|
||||
loading, and empty ("working tree clean") states distinctly; `file_tree_view.dart`
|
||||
handles error + loading.
|
||||
- **Manual a11y discipline is documented.** `docs/testing/a11y-manual.md` prescribes a
|
||||
per-tier Orca/VoiceOver pass and is honest about why prose quality can't be automated.
|
||||
- **Disabled state is handled at the cursor level.** `clide_button.dart:41` switches to
|
||||
`SystemMouseCursors.forbidden` and drops the semantic `onTap` when `onPressed == null`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `ClideTappable` is mouse-only — no `Focus`, no keyboard activation.**
|
||||
`lib/widgets/src/clide_tappable.dart:37-54` is `MouseRegion` + `GestureDetector` only.
|
||||
It is the base for `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, and
|
||||
most builtin list items. None can receive Tab focus or be activated with Enter/Space.
|
||||
The keyboard-traversal test only passes because it manually wraps the button in an
|
||||
external `Focus` node — it tests that the widget doesn't *block* focus, not that it
|
||||
*accepts* it.
|
||||
- **[Critical] Command palette is not keyboard-navigable.** `clide_palette.dart` —
|
||||
`onSubmitted` only ever invokes `filtered.first` (`:77-80`); no up/down handling, no
|
||||
selected index, no selection highlight, no Escape handler.
|
||||
- **[Major] No focus-traversal wiring between panels.** `FocusTracker`
|
||||
(`lib/kernel/src/focus.dart`) tracks an active *contribution id* for the `clide active`
|
||||
CLI, but is not Flutter `FocusScope`/`FocusTraversalGroup` integration. Nothing
|
||||
establishes Tab order across sidebar → workspace → context.
|
||||
- **[Major] Drag-resize handles have no keyboard equivalent — parity gap.**
|
||||
`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers, with no
|
||||
Semantics node at all. Per "User/Claude parity", panel sizing should have a CLI
|
||||
affordance; none is evident.
|
||||
- **[Major] Single global `KeyboardListener` is a fragile keybinding architecture.**
|
||||
`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no
|
||||
per-context scoping, will conflict with text-input fields.
|
||||
`KeybindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel`.
|
||||
- **[Major] Text scale is the *only* in-app a11y accommodation, and it's hidden.**
|
||||
`app.dart:122-138` implements Ctrl +/-/0 text scaling but it's undiscoverable. No
|
||||
high-contrast toggle, no reduced-motion handling, no focus-ring rendering anywhere.
|
||||
- **[Minor] Contrast gate covers only 11 token pairs** — omits `globalTextMuted` (muted
|
||||
text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and
|
||||
`panelActiveBorder`.
|
||||
- **[Minor] ~43 hardcoded-color sites bypass the token system** — some defensible (ANSI
|
||||
palette), but the modal/palette shadow and window-control colors won't adapt to the
|
||||
`paper` light theme.
|
||||
- **[Minor] Hover state is inconsistent and not paired with focus** — every interactive
|
||||
widget reimplements its own `_hover` bool; none render a focus indicator.
|
||||
- **[Minor] `_LeftHatContent` is dead code** — `app.dart:281-292` always returns
|
||||
`SizedBox.shrink()`.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add a `Focus` + `Actions`/`Shortcuts` (Enter/Space → onTap) wrapper and
|
||||
a focus-ring inside `ClideTappable` — fixes the [Critical] for every button/list-item at
|
||||
once; add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern at `app.dart:446-452`); expand
|
||||
`canonicalPairs`; surface text-zoom and theme switching in the palette; tokenize the
|
||||
modal shadow and window-control colors.
|
||||
|
||||
**Larger efforts:** establish a real focus-traversal model and integrate `FocusTracker`
|
||||
with Flutter's focus system; replace the root `KeyboardListener` with scoped
|
||||
`Shortcuts`/`Actions` and move off `keyLabel`; add keyboard operability + Semantics to
|
||||
drag-resize handles plus a `clide panel resize` CLI; add an a11y test tier that asserts
|
||||
*operability*, not just Semantics presence.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Interaction model | 2/5 | Coherent slot/panel structure and good drag-resize *with a mouse*, but keyboard operability is largely unbuilt. |
|
||||
| Accessibility | 3/5 | Genuine contrast gate, Semantics on composed widgets, i18n contract tests — but keyboard operability and focus order are not implemented. |
|
||||
| Visual consistency | 4/5 | Strong semantic token system consumed consistently; a few hardcoded-color sites are real theme-adaptation bugs. |
|
||||
| Discoverability | 2/5 | Command palette isn't keyboard-navigable; accommodations are undiscoverable; no in-app keybinding reference. |
|
||||
| State coverage | 3/5 | Data panels and dialogs handle loading/error/empty; but no focus states anywhere and no reduced-motion handling. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Code quality & craft — Senior Dart/Flutter Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is, for a solo pre-v2.0 project, in genuinely good shape. The core subsystems (IPC
|
||||
envelope, daemon dispatch, git client, PTY) are small, single-responsibility,
|
||||
well-typed, and consistent. `flutter analyze` is clean for `lib/` — the 9 reported issues
|
||||
are all in `test/`, none are suppressions. The biggest strength is the FFI/PTY layer:
|
||||
`lib/src/pty/native_pty.dart` shows real systems-programming discipline (pre-fork
|
||||
allocation, errno captured before `free`, isolate-teardown ordering documented and
|
||||
correct). The biggest risk is concentrated in two places: a genuine isolate-leak race in
|
||||
`SchedulerService`, and the large vendored-but-owned `lib/src/terminal/` xterm.dart fork
|
||||
(~7k LOC) which carries a different style, commented-out `print`s, and dangling TODOs
|
||||
that the project's own "lib is owned, not vendored" rule says must be held to the same
|
||||
bar.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **PTY/FFI layer is excellent.** `native_pty.dart:129-145` force-resolves FFI
|
||||
trampolines and pre-allocates *all* native memory before `forkpty()`.
|
||||
`native_pty.dart:171-177` captures `errno` before `_freeAll` because `free()` can
|
||||
clobber it. `close()` (367-397) documents and implements the kill→EOF→close ordering
|
||||
to avoid fd-reuse races. The child branch touches no Dart heap.
|
||||
- **IPC envelope is clean and idiomatic** — `lib/src/ipc/envelope.dart` uses a `sealed`
|
||||
class hierarchy, named constructors, a private unifying constructor, and conditional
|
||||
map keys. Decode is total over the type discriminant.
|
||||
- **Typed, meaningful errors.** `PtyException` carries `op` + optional `errno`;
|
||||
`GitException` carries `stderr`; `errnoToIpcError` maps POSIX errno to actionable IPC
|
||||
error kinds. Errors are values, not strings.
|
||||
- **Resource lifecycle is taken seriously across most subsystems.** `FileWatcher.stop()`
|
||||
cancels the subscription *and* closes the controller; `withBuffer`/`setWinsize` in
|
||||
`libc.dart` use `try/finally` around every native allocation. 45 files define
|
||||
`dispose`/`close`.
|
||||
- **The `DaemonEventSink` interface** keeps the dependency graph pointing the right way
|
||||
(server→subsystems) and is documented as such.
|
||||
- **The one `ignore_for_file` (`libc.dart:11-27`) is exemplary** — textbook FFI case,
|
||||
multi-paragraph justification exactly as CLAUDE.md requires.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] Isolate-leak race in `SchedulerService._startTicker`** — `scheduler.dart:71`:
|
||||
`Isolate.spawn(...).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the
|
||||
spawn future completes, `_isolate` is still null, nothing is killed, and the
|
||||
just-spawned isolate (with its `Timer.periodic`) leaks. `native_pty.dart` solved
|
||||
exactly this with `_readerReady`.
|
||||
- **[Major] `lib/src/terminal/` held below the project's own bar.** Carries
|
||||
commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs
|
||||
(`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only
|
||||
`// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`).
|
||||
Either it's genuinely vendored (belongs in `native/` or documented as frozen) or it's
|
||||
owned (needs the cleanup pass).
|
||||
- **[Minor] Empty `catch (_) {}` swallows in `tree_sitter_ffi.dart:197,206`** —
|
||||
`DynamicLibrary.open` failures silently discarded; caller gets a bare `null` with no
|
||||
diagnostic about *why*. Syntax highlighting silently not working is a support
|
||||
headache.
|
||||
- **[Minor] Empty `catch (_) {}` in `test_app.dart:271,311`** — `:271` swallows a
|
||||
theme-load failure the harness exists to detect.
|
||||
- **[Minor] Dead alias in `libc.dart:201-202`** — `typedef Cmsghdr = CmsghdrLinux;`
|
||||
flagged "backward compatibility"; CLAUDE.md forbids backwards-compat hacks in a solo
|
||||
repo.
|
||||
- **[Minor] `// ignore: unused_field` in `editor_controller.dart:25`** "kept for future
|
||||
subscription changes" — speculative retention; the no-suppression rule wants it fixed,
|
||||
not silenced.
|
||||
- **[Minor] Magic numbers in hot FFI paths.** `native_pty.dart` inlines `0x0001
|
||||
// POLLIN`, `28 /* SIGWINCH */`, `4 /* EINTR */`, `9 /* EBADF */` — but `libc.dart`
|
||||
already has a constants section and `errno_mapping.dart` has `PosixErrno.ebadf`.
|
||||
- **[Minor] `git_commands.dart` has ~16 near-identical handler bodies** — a
|
||||
`_guarded(req, () async {...})` helper would remove ~60 lines of structural
|
||||
duplication. Borderline.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix the `SchedulerService` spawn race (track the spawn future like
|
||||
`NativePty._readerReady`); replace the three silent `catch (_)` in `tree_sitter_ffi.dart`
|
||||
with a logged last-error; delete the `Cmsghdr` alias and the `unused_field` suppression;
|
||||
have the PTY layer consume `libc.dart` constants / `PosixErrno` instead of inline hex.
|
||||
|
||||
**Larger efforts:** decide the status of `lib/src/terminal/` — formally vendor it
|
||||
(freeze, document, decision-record) or do the cleanup sweep; optionally a `_guarded`
|
||||
helper for `git_commands.dart` (check whether `files_commands` / `editor_commands` share
|
||||
the shape).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Idiomatic Dart | 4/5 | Sealed classes, named ctors, records, `const`, immutability used well; the vendored terminal tree pulls the average down. |
|
||||
| Error handling | 4/5 | Typed errors with context everywhere in core; a few silent `catch (_)` in the FFI loader and test harness cost the 5th point. |
|
||||
| Naming & readability | 4/5 | Clear, intention-revealing names; comments earn their place; inline magic numbers are the main blemish. |
|
||||
| Consistency across subsystems | 3/5 | IPC/git/files/pty are uniform; `lib/src/terminal/` is a different codebase in style; PTY duplicates constants `libc.dart` owns. |
|
||||
| Resource/lifecycle safety | 4/5 | `try/finally` around native allocs, controllers closed, subscriptions cancelled; the one real defect is the `SchedulerService` race. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Security & supply chain — Security Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide's security posture is **above average for a solo pre-v2.0 project**. All
|
||||
subprocess calls use `Process.run`/`Process.start` with argument *lists* (no shell
|
||||
interpolation), the IPC transport is a per-user Unix socket (not a TCP port), and there
|
||||
is an explicit `path_safety` module with a containment check. The single biggest strength
|
||||
is the disciplined no-shell subprocess layer. The single biggest risk is
|
||||
**untrusted-workspace code execution via toolchain resolution**
|
||||
(`toolchain_paths.dart:79`): a malicious repo can ship a `native/dugite/bin/git`
|
||||
executable that clide will resolve and run. Secondary real issues: path-safety does not
|
||||
defend against symlink escape, and IPC command args are largely unvalidated/un-bounded.
|
||||
Supply-chain hygiene is mostly good but `licenses.yaml` has drifted from `pubspec.yaml`
|
||||
and native binaries are committed without SHA pinning.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **No-shell subprocess execution.** `GitClient._run` (`client.dart:210`),
|
||||
`PqlClient._run` (`client.dart:165`), and the PTY layer all pass `List<String>` args
|
||||
directly. Classic command injection is structurally prevented.
|
||||
- **Toolchain uses resolved absolute paths** — git/pql/tmux resolved once to absolute
|
||||
paths and reused.
|
||||
- **Path containment check exists and is used.** `resolveUnderRoot`
|
||||
(`path_safety.dart:21`) collapses `..`/`.` without touching the filesystem and enforces
|
||||
a prefix check with a separator guard. `files.read`/`files.ls` both call it.
|
||||
- **IPC is a per-user Unix socket, not a network listener.** No `ServerSocket` over TCP
|
||||
anywhere; the default runtime path is in-process, eliminating the socket attack
|
||||
surface in the shipped app.
|
||||
- **PTY FFI memory discipline** — all native memory allocated before `forkpty()`, `errno`
|
||||
captured before `free()`, freed on every path.
|
||||
- **`pubspec.lock` is committed**, deps use exact pins (no carets), `licenses.yaml`
|
||||
exists with per-dep purpose/license.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] Malicious workspace can plant a git binary that clide executes.**
|
||||
`toolchain_paths.dart:79-84` builds `'$workspaceRoot/native/dugite/bin'` and runs
|
||||
`_firstExisting(['$dugite/git'])`; if that file exists it becomes the git binary for
|
||||
all `GitClient` calls, **before** falling back to PATH. An attacker commits an
|
||||
executable at `native/dugite/bin/git`; clide runs it on the first `git.status` (which
|
||||
fires automatically on workspace open). Arbitrary code execution from merely opening a
|
||||
repo. The `native/dugite` convention should resolve relative to the *clide install
|
||||
dir*, never the workspace root.
|
||||
- **[Major] Path-safety does not defend against symlink escape.** `path_safety.dart:
|
||||
35-51` explicitly does not resolve symlinks, and the filesystem layer
|
||||
(`files_commands.dart:81-85`) never does either. A repo symlink `config -> /etc/shadow`
|
||||
passes the containment check (the *link path* is under root) and clide reads the
|
||||
target. Fix: after `resolveUnderRoot`, `resolveSymbolicLinksSync()` and re-verify
|
||||
containment.
|
||||
- **[Major] IPC command arguments are unvalidated and unbounded.**
|
||||
`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson`
|
||||
(`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count
|
||||
cap on `git.log`, no check that `git.checkout`'s `branch` (`git_commands.dart:240`)
|
||||
isn't a `-`-prefixed flag. `git diff`/`stage` use `--` separators (good), but
|
||||
`checkout(branch)` and `push(remote, branch)` do not — argument injection
|
||||
(`git checkout --upload-pack=...`) is possible.
|
||||
- **[Minor] macOS entitlements disable library validation.**
|
||||
`macos/Runner/Release.entitlements` sets `disable-library-validation` = true with no
|
||||
App Sandbox entitlement. Arguably needed for the `dlopen` of `libtree-sitter.so`, but
|
||||
combined with no sandbox a compromised process has full user-level filesystem access.
|
||||
- **[Minor] `licenses.yaml` has drifted from `pubspec.yaml`.** Lists dev-dep `test` at
|
||||
`1.25.8` but `pubspec.yaml:60` pins `1.30.0`; lists a `lints 5.0.0` not in
|
||||
`pubspec.yaml` at all. The two-step-commit guardrail is being violated.
|
||||
- **[Minor] Native binaries committed without SHA pinning.** `native/linux-x64/` has
|
||||
`libtree-sitter.so` (24 MB) and `ptyc` (22 KB) committed with no `SHA256SUMS` manifest.
|
||||
CLAUDE.md says native deps are "pinned by SHA"; that pinning is not evidenced.
|
||||
- **[Informational] No secrets service** — clide stores no tokens; git auth is delegated
|
||||
to the system credential helper. The right call; noted so the absence reads as
|
||||
deliberate.
|
||||
- **[Informational] Lua runtime is a stub** — `lib/lua/src/host.dart` is Tier-0. Design
|
||||
intent (strip `io`/`os.execute`/`package.loadlib`/`debug`) is sound; re-assess at Tier
|
||||
6 — sandbox-escape via FFI re-entry will be the concern.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix toolchain resolution to resolve `native/dugite` against
|
||||
`Platform.resolvedExecutable`'s directory, never `workspaceRoot` (closes the Critical);
|
||||
add symlink re-check in `files.read`/`files.ls`; reconcile `licenses.yaml` with
|
||||
`pubspec.yaml`; reject `-`-prefixed values for `branch`/`remote`/`path` args (or use
|
||||
`--` everywhere, including `checkout`).
|
||||
|
||||
**Larger efforts:** schema-validate the IPC surface with typed arg schemas + size/count
|
||||
bounds; add a committed `native/SHA256SUMS` verified by `make` and CI; revisit macOS
|
||||
sandboxing (App Sandbox with explicit exceptions); security-review the Lua FFI boundary
|
||||
and capability table before Tier 6 ships.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Rating | Justification |
|
||||
|---|---|---|
|
||||
| Subprocess safety | 2/5 | No-shell arg lists are excellent, but the workspace-relative dugite path is a real RCE; argument-injection on `checkout`/`push` unmitigated. |
|
||||
| IPC input validation | 3/5 | Per-user Unix socket + in-process default sharply limits exposure, but zero arg-schema validation and no size/count bounds. |
|
||||
| Path/filesystem safety | 3/5 | Real containment check that's actually wired in, undermined by the unhandled symlink-escape gap. |
|
||||
| Dependency/supply-chain hygiene | 3/5 | Exact pins, committed lockfile, documented deps — but `licenses.yaml` drift and missing SHA manifest for committed native binaries. |
|
||||
| Secrets & sandboxing | 3/5 | Correctly delegates secrets; Lua sandbox is only a stub; macOS runs with library validation off and no App Sandbox. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Docs, governance & DX — TPM / Developer-Experience Consultant
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide runs an unusually disciplined governance system for a solo-dev pre-v2.0 project:
|
||||
67 decision records across six domains, with a parser-validated DQR structure, anchored
|
||||
cross-references, and a `make decisions-validate` gate wired into pre-push. The biggest
|
||||
strength is that the DQR system is genuinely *alive* — questions get resolved with dated
|
||||
amendments, superseded decisions are marked, and decisions cite the commits that
|
||||
implement them. The biggest risk is **documentation drift in the narrative docs**:
|
||||
`README.md` and `docs/initial-plan.md` describe an architecture (Go sidecar, `ptyc/` C
|
||||
helper, `app/` subdirectory, separate daemon) that three major decisions (D-5, D-56, the
|
||||
FFI pivot) have since dissolved. A new contributor reading the README first would build
|
||||
a wrong mental model.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **DQR system is maintained, not ornamental.** Resolved questions carry dated
|
||||
resolution lines pointing to the deciding D-record (`questions/architecture.md:39`
|
||||
Q-6→D-57). D-40 carries a `[SUPERSEDED]` tag and an amendment line.
|
||||
- **Decisions are linked to code and commits.** D-67 (`decisions/process.md:61`) cites
|
||||
implementing commits `01a99ed`, `d162ba2`. D-66 references `ci/test.sh` by path.
|
||||
- **Governance migration was done cleanly** — the `decisions/` → `governance/`
|
||||
restructure updated cross-references and the auto-generated index.
|
||||
- **Commit discipline is real.** `git log` shows imperative subjects, no Conventional
|
||||
Commits prefixes, ticket refs, logical scoping — exactly what `git-commit/SKILL.md`
|
||||
prescribes.
|
||||
- **`licenses.yaml` is thorough** — all six runtime Dart deps present, plus fonts/native
|
||||
libs, with purpose justifications. *(Note: the Security reviewer found version drift
|
||||
in this file — see Finding above; the two reviewers examined different rows.)*
|
||||
- **Makefile is self-documenting** (`##` help annotations) and matches `CLAUDE.md`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `docs/initial-plan.md` is badly stale.** The "north-star" doc (linked
|
||||
from `CLAUDE.md:14` and `README.md:44`) still describes a Go sidecar
|
||||
(`initial-plan.md:4,55,189`), `clide --daemon` long-running process (`:162-164`),
|
||||
`app/` subdirectory layout (`:184-204`), and `project.yaml` (`:172`) — all contradicted
|
||||
by D-5, D-56, and the single-package-at-root reality. Nothing flags it as historical.
|
||||
- **[Critical] `README.md` describes a dissolved architecture.** `README.md:10`
|
||||
documents `ptyc/` as a live component; `README.md:36` lists `make ptyc-build`. The
|
||||
`ptyc/` directory does not exist, the Makefile has no such target, and the CHANGELOG's
|
||||
own Unreleased section records ptyc's removal.
|
||||
- **[Major] `README.md:44` links to `decisions/`** — a directory that no longer exists
|
||||
(migrated to `governance/`). Dead link in the primary onboarding doc.
|
||||
- **[Major] CHANGELOG has duplicate subsection headings in `[Unreleased]`.** Three
|
||||
`### Changed` blocks (`CHANGELOG.md:100, 114, 168`), two `### Fixed`, two `### Removed`
|
||||
in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release.
|
||||
- **[Major] No `CONTRIBUTING.md` or onboarding doc.** For a project "intended to ship
|
||||
publicly to other developers," there is no contributor guide; the build/test story is
|
||||
scattered across `CLAUDE.md` (Claude-oriented), `README.md` (partly wrong), and
|
||||
Makefile help.
|
||||
- **[Major] Coverage-floor governance contradicts itself.** D-66 (`testing.md:65`) says
|
||||
the floor lives at `coverage/floor.txt` starting "≈35%"; `CHANGELOG.md:44-46` says it's
|
||||
in `pubspec.yaml` `coverage_floor:` starting at 34%; the latest commit is `9030e56
|
||||
hold coverage_floor fixed at 90`. Three sources, three mechanisms/values. D-66 was
|
||||
never amended.
|
||||
- **[Minor] `ci/release.sh` is a stub that still references goreleaser/sidecar** — Go
|
||||
tooling for a project with no Go.
|
||||
- **[Minor] CHANGELOG `[2.0.0] — 2026-05-03` dating** — the v2.0.0 tag is dated
|
||||
2026-05-03 but the enormous Unreleased section represents ~80 commits of post-tag work
|
||||
with no interim version.
|
||||
- **[Minor] Stale-ish open questions** — Q-25 (body text face) is de facto resolved by
|
||||
D-43/D-44 and the shipped impl; Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are
|
||||
due for triage now that Tier 1 has shipped.
|
||||
- **[Minor] Skills system is coherent but undocumented as a set** — eight skills under
|
||||
`.claude/skills/`, no index.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** rewrite `README.md`'s `ptyc/` sections and fix the `decisions/` link;
|
||||
banner `docs/initial-plan.md` as historical (or split out a current
|
||||
`docs/architecture.md`); merge the duplicate changelog subsection headings; amend D-66 to
|
||||
reflect the floor's actual location/mechanism/value with a dated amendment line; triage
|
||||
Q-1/2/3/25.
|
||||
|
||||
**Larger efforts:** write a human-facing `CONTRIBUTING.md` (clone →
|
||||
`make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions); cut
|
||||
an interim release to drain the ~80-commit Unreleased backlog; add a
|
||||
`.claude/skills/README.md` inventory; establish a periodic governance sweep (the repo
|
||||
even has a `clean-house` skill for exactly this).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Score | Justification |
|
||||
|---|---|---|
|
||||
| Governance discipline | 4/5 | DQR system genuinely maintained — but D-66 drift and untriaged Tier-1-era questions show the sweep cadence lags the code. |
|
||||
| Documentation accuracy | 2/5 | Both primary onboarding docs describe a dissolved Go-sidecar/ptyc/daemon architecture; `CLAUDE.md` is accurate by contrast. |
|
||||
| Changelog hygiene | 3/5 | Per-commit discipline is followed, but duplicate subsection headings violate the standard and an 80-commit Unreleased backlog undermines the format. |
|
||||
| Contributor onboarding | 2/5 | No `CONTRIBUTING.md`; build story split across three docs, one wrong; `CLAUDE.md` is Claude-addressed, not human-addressed. |
|
||||
| Convention adherence | 4/5 | Commit style, DQR claiming, `licenses.yaml` two-step rule demonstrably followed; docked for the changelog defects and the README gap. |
|
||||
|
||||
---
|
||||
|
||||
## Closing note
|
||||
|
||||
The recurring pattern across all six reviews: **clide's foundations are excellent and
|
||||
its finishing is incomplete.** The extension contract, test helpers, FFI discipline,
|
||||
governance system, and token system are all things most projects never get right. The
|
||||
gaps — IPC not wired, keyboard not operable, docs describing a dead architecture, a
|
||||
workspace-relative binary path — are all the kind of thing that happens when a fast-moving
|
||||
solo project's implementation outruns its connective tissue. They are concentrated, not
|
||||
diffuse, and the quick-win column above would close most of the critical ones in a few
|
||||
focused days.
|
||||
@@ -142,6 +142,7 @@ You might also want, project-permitting:
|
||||
- [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_
|
||||
- [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
|
||||
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
|
||||
- [D-101: ClideSettings — one live-preferences access facade](decisions/architecture.md#d-101-clidesettings--one-live-preferences-access-facade) — _architecture_
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -534,4 +534,12 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer), [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](#d-97-ssh-workspace-uri--system-ssh-auth). Implemented across T-332 (identity carrier) and T-333 (session re-key).
|
||||
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("session identity keyed on (host, repo) amending D-41/D-77").
|
||||
|
||||
### D-101: ClideSettings — one live-preferences access facade
|
||||
- **Date:** 2026-06-17
|
||||
- **Decision:** Live, user-selectable preferences are read through a single widget-facing facade, `ClideSettings`, namespaced by concern: `ClideSettings.fonts.monoOf(context)` / `.fonts.uiOf(context)`, `ClideSettings.theme.of(context)`, `ClideSettings.i18n.of(context)`. Values originate in the kernel `SettingsStore`; the app root (`root_shell`) resolves them and provides a `ClideSettingsScope` InheritedWidget (carrying the font families), rebuilding it on a settings change so dependents re-read live. Theme and i18n **delegate** to their existing live providers (`ClideTheme` / the `I18n` service) rather than being duplicated — one source of truth. Reads outside a scope fall back to the bundled font defaults, so a widget renders without a provider (isolated tests). "Plumb once, use many."
|
||||
- **Rationale:** Before this, each live setting had its own ad-hoc read path — theme via `ClideTheme.of`, i18n via `ClideKernel.of(context).i18n`, fonts as a compile-time `const` that couldn't change at runtime at all. A new preference meant inventing another path. One facade gives every current and future preference a uniform, discoverable read site and a single root resolution point — without a mutable global (the shortcut rejected during the T-471 font-flow design) and without a big-bang rewrite of the established theme/i18n providers (they delegate, so their consumers migrate incrementally).
|
||||
- **Cost:** Two read paths coexist during migration — `ClideTheme.of` / `i18n.string` still work (the facade delegates to them), so their hundreds of call sites move to `ClideSettings.theme` / `.i18n` incrementally rather than at once. The font consts (`clideMonoFamily` / `clideUiFamily`) remain as the facade's defaults; ~11 context-less helper sites still read the const directly, migrated in a follow-up (T-472). The facade lives in `widgets` and reaches into `kernel` for the theme/i18n delegates (a dependency already present).
|
||||
- **Cross-reference:** Fonts landed it: T-460 (Inter default + UI picker) and T-471 (mono picker) migrated ~93 sites onto `ClideSettings.fonts`. Consumer migration of theme + i18n onto the facade, and the context-less font stragglers (T-472), are staged follow-ups. Values live in the kernel `SettingsStore`.
|
||||
- **Raised by:** 2026-06-17 — user, during T-471 font-flow design: "I do see reason in nesting them all in one settings object that dynamically loads so we can extend it in the future… plumb once, use many."
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +31,7 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
||||
- **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already.
|
||||
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
- **Amendment (2026-06-17):** Reversed — the rebuild **does** use [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) after all. Format is `type(scope): imperative subject`, with the standard type set (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`); `scope` is the subsystem (`settings`, `vim`, `pty`, `plan`, …); append `!` after the scope for a breaking change; keep a trailing `(T-NNN)` ticket ref where one applies. Subject (prefix included) stays ≤ 72 chars; the no-emoji, body, HEREDOC, and attribution-trailer rules from the original decision are unchanged. Practice had already drifted to this form (`feat(settings): category rail + navigation (T-447)`); the decision now matches it. `.claude/skills/git-commit/SKILL.md`, `CONTRIBUTING.md`, and `POLICY.md` updated to suit. **Why the reversal:** the original "types are noise" call didn't hold up — scoped types make `git log` skimmable and the changelog subsection (Added/Fixed/…) maps cleanly onto the commit type.
|
||||
|
||||
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
||||
- **Date:** 2026-04-21
|
||||
|
||||
@@ -37,10 +37,13 @@ class ClaudeBanner extends StatelessWidget {
|
||||
const SizedBox(height: 18),
|
||||
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 16),
|
||||
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
||||
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
|
||||
if (statusLine != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
||||
],
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
|
||||
import 'package:clide/builtin/claude/src/running_indicator.dart';
|
||||
import 'package:clide/builtin/claude/src/slash_commands.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -415,7 +414,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
final hasText = _controller.text.isNotEmpty;
|
||||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'model_picker_card.dart';
|
||||
import 'permission_mode_control.dart';
|
||||
import 'session_defaults.dart';
|
||||
import 'prompt_card.dart';
|
||||
import 'session_index.dart';
|
||||
import 'session_naming.dart';
|
||||
@@ -131,12 +132,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final seg = statusSegmentsAroundMode(_status);
|
||||
final mode = _status.permissionMode;
|
||||
|
||||
Widget text(String t) => ClideText(t, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1);
|
||||
Widget text(String t) =>
|
||||
ClideText(t, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusBarForeground, maxLines: 1);
|
||||
|
||||
final children = <Widget>[];
|
||||
void add(Widget w) {
|
||||
if (children.isNotEmpty) {
|
||||
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted, maxLines: 1));
|
||||
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted, maxLines: 1));
|
||||
}
|
||||
children.add(w);
|
||||
}
|
||||
@@ -294,6 +296,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
// New sessions inherit the user's Claude defaults (T-457): effort flows
|
||||
// through --effort below; model + permission mode are applied post-spawn.
|
||||
final settings = ClideKernel.of(context).settings;
|
||||
_effort ??= defaultEffortFlag(settings);
|
||||
var isNewSession = false;
|
||||
final ipc = _ipc();
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Daemon not connected.');
|
||||
@@ -370,11 +377,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
isNewSession = !resume;
|
||||
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
||||
}
|
||||
|
||||
_session = managed.session;
|
||||
_conversation = managed.conversation;
|
||||
// A brand-new session (not a resume or fork) starts on the user's default
|
||||
// model + permission mode (T-457). Sent as control requests; the CLI
|
||||
// applies them after init. 'default'/unset values are no-ops.
|
||||
if (isNewSession) applySessionDefaults(managed.session, settings);
|
||||
// The wire never reports effort — record what this session was spawned
|
||||
// with so the status line / sidebar can show it (T-412).
|
||||
if (_effort != null) managed.session.noteEffort(_effort!);
|
||||
@@ -717,7 +729,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
@@ -846,7 +858,13 @@ class _ModeBadge extends StatelessWidget {
|
||||
return Semantics(
|
||||
label: 'permission mode: ${permissionModeLabel(mode)}',
|
||||
excludeSemantics: true,
|
||||
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
|
||||
child: ClideText(
|
||||
permissionModeLabel(mode),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: permissionModeColor(mode, tokens),
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -30,7 +29,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
final tasks = widget.tasks;
|
||||
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
||||
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
||||
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
||||
|
||||
@@ -175,7 +175,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -241,13 +241,13 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
if (widget.collapsible) _caret(tokens),
|
||||
// Header label size matches ClideCollapserCard (clideFontCaption) so
|
||||
// neighbouring cards in the conversation stream align (T-344).
|
||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: clideMonoFamily),
|
||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
// While collapsed, show a one-line gist next to the label so the card
|
||||
// still says what it holds.
|
||||
if (_collapsed && summary != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1),
|
||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context), maxLines: 1),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
@@ -322,7 +322,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
||||
],
|
||||
@@ -349,7 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: items[i].onTap,
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -27,7 +27,6 @@ import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
@@ -293,7 +292,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final allItems = widget.controller.items;
|
||||
// T-263/T-264: resolve each sidechain run → owning Agent card before
|
||||
// culling, so the run is suppressed up top and folded into / nested under
|
||||
@@ -336,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
key: ValueKey('turn.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
collapseTools: true,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
@@ -547,6 +547,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.tokens,
|
||||
required this.mono,
|
||||
this.collapseTools = false,
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
@@ -560,6 +561,11 @@ class _ConversationTurn extends StatelessWidget {
|
||||
final ConversationItem item;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// The live monospace family (T-471/T-472), resolved from context by the
|
||||
/// parent and threaded in so the context-free tool-body/result helpers honour
|
||||
/// the Settings → Appearance choice.
|
||||
final String mono;
|
||||
|
||||
/// When true (top-level stream items), a tool use renders as its own
|
||||
/// collapser over a one-item list (T-305). When false (already inside a run /
|
||||
/// edit collapser), it renders the bare inner content card so collapsers
|
||||
@@ -788,6 +794,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
key: ValueKey('run.${r.uuid}'),
|
||||
item: r,
|
||||
tokens: tokens,
|
||||
mono: mono,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
@@ -948,7 +955,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
label: t.name,
|
||||
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
status: status,
|
||||
body: toolInputBody(tokens, t.name, t.input),
|
||||
body: toolInputBody(tokens, t.name, t.input, mono),
|
||||
extraSegments: segments,
|
||||
// Inside a collapser the surrounding padding is even on all sides
|
||||
// (T-305): a matching bottom margin is the canvas's bottom inset and the
|
||||
@@ -1005,7 +1012,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsible: quiet || multiline,
|
||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1026,7 +1033,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: isOutputTool
|
||||
? ClideCodeBlock(source: t.content, language: 'text')
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1089,6 +1096,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
key: ValueKey('step.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
@@ -1154,6 +1162,7 @@ class _EditRunCard extends StatelessWidget {
|
||||
key: ValueKey('edit.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
|
||||
@@ -10,7 +10,9 @@ import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||||
import 'package:clide/builtin/claude/src/session_defaults.dart';
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
import 'package:clide/builtin/claude/src/session_storage.dart';
|
||||
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||
@@ -100,6 +102,81 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: cycle activity fold level',
|
||||
run: _cycleFoldLevel,
|
||||
),
|
||||
// Activity settings category (T-453) — the fold level as a schema field,
|
||||
// written to kActivityFoldLevelKey; the panes already rebuild off the
|
||||
// settings notifier, so picking a level applies live.
|
||||
const SettingsCategoryContribution(
|
||||
id: 'activity',
|
||||
category: SettingsCategory(
|
||||
id: 'activity',
|
||||
title: 'Activity',
|
||||
iconName: 'cards-three',
|
||||
priority: 50,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Conversation',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kActivityFoldLevelKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Fold level',
|
||||
help: 'How aggressively the conversation folds tool calls, thinking, and results.',
|
||||
defaultValue: 'tools',
|
||||
options: [
|
||||
SettingsOption(value: 'none', label: 'Show everything'),
|
||||
SettingsOption(value: 'tools', label: 'Fold tool calls'),
|
||||
SettingsOption(value: 'thinking', label: 'Fold tools + thinking'),
|
||||
SettingsOption(value: 'everything', label: 'Fold all but prose'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Claude settings category (T-457) — defaults applied to NEW sessions
|
||||
// (the pane reads these keys at spawn). Effort flows through --effort;
|
||||
// model + permission mode are sent as control requests post-spawn.
|
||||
SettingsCategoryContribution(
|
||||
id: 'claude',
|
||||
category: SettingsCategory(
|
||||
id: 'claude',
|
||||
title: 'Claude',
|
||||
iconName: 'sparkle',
|
||||
priority: 40,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'New session defaults',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kDefaultModelKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Model',
|
||||
help: 'Model for new sessions.',
|
||||
defaultValue: 'default',
|
||||
options: [for (final m in kFallbackModels) SettingsOption(value: m.value, label: m.displayName)],
|
||||
),
|
||||
SettingsField(
|
||||
key: kDefaultEffortKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Effort',
|
||||
help: 'Reasoning effort for new sessions (applied via --effort at spawn).',
|
||||
defaultValue: 'high',
|
||||
options: [for (final l in kEffortLevels) SettingsOption(value: l.value, label: l.displayName)],
|
||||
),
|
||||
SettingsField(
|
||||
key: kDefaultPermissionModeKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Permission mode',
|
||||
help: 'Starting permission mode for new sessions.',
|
||||
defaultValue: 'default',
|
||||
options: [for (final p in kPermissionModes) SettingsOption(value: p.value, label: p.displayName)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.show <sessionId>
|
||||
CommandContribution(
|
||||
|
||||
@@ -25,7 +25,7 @@ void openImageLightbox(BuildContext context, String path) {
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context, double size) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -46,7 +46,7 @@ class ImageThumbnail extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Image $_fileName',
|
||||
|
||||
@@ -45,7 +45,7 @@ class ActivityTabView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final latest = stats.latest;
|
||||
final u = usage;
|
||||
final sections = <MetaSection>[
|
||||
|
||||
@@ -41,7 +41,7 @@ class ConfigTabView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
@@ -299,7 +299,7 @@ class _SettingControlRowState extends State<SettingControlRow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -29,7 +28,7 @@ class MetaIconButton extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideSettings.theme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -16,7 +15,7 @@ class SidebarTabStrip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -18,7 +18,7 @@ class TaskRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
|
||||
@@ -48,7 +48,7 @@ class TeamTabView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -130,7 +129,7 @@ class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
@@ -147,9 +146,14 @@ class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
|
||||
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusInfo),
|
||||
const Spacer(),
|
||||
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
ClideText(
|
||||
'↑↓ · 1-${widget.models.length} · Enter · Esc',
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -88,13 +88,13 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -103,7 +102,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens);
|
||||
// Autofocus the card so number keys pick a button/option on appear (T-240).
|
||||
// _onKey self-guards via hasPrimaryFocus, so once the user clicks a note
|
||||
@@ -122,7 +121,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent),
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: accent),
|
||||
const SizedBox(height: 8),
|
||||
...children,
|
||||
],
|
||||
@@ -259,7 +258,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
// or file body is visible but doesn't swamp the composer zone (D-78).
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SingleChildScrollView(child: _inputBody(tokens, p)),
|
||||
child: SingleChildScrollView(child: _inputBody(tokens, ClideSettings.fonts.monoOf(context), p)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
@@ -284,7 +283,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
|
||||
/// Render the tool input in the shape that best fits the tool. Delegates to
|
||||
/// the shared top-level helpers (also used by ConversationView — T-168).
|
||||
Widget _inputBody(SurfaceTokens tokens, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input);
|
||||
Widget _inputBody(SurfaceTokens tokens, String mono, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input, mono);
|
||||
|
||||
// -- AskUserQuestion: single = bare, multi = stepper + review --------------
|
||||
|
||||
@@ -362,14 +361,21 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
border: Border.all(color: tokens.statusInfo),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalForeground),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
||||
chips.add(
|
||||
ClideText(
|
||||
text,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: done ? tokens.statusSuccess : tokens.globalTextMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted));
|
||||
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted));
|
||||
chips.add(ClideText('›', color: tokens.globalTextMuted, fontSize: 15));
|
||||
return Wrap(spacing: 10, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: chips);
|
||||
}
|
||||
@@ -399,7 +405,8 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (q.header.isNotEmpty) ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
if (q.header.isNotEmpty)
|
||||
ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: ClideText(q.question, color: tokens.globalForeground),
|
||||
@@ -484,19 +491,19 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
///
|
||||
/// Shared between [ToolPromptCard] (permission prompt body) and the
|
||||
/// [ConversationView] tool-use card bodies (T-168).
|
||||
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
||||
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
return toolBashBody(tokens, input);
|
||||
case 'Write':
|
||||
return toolWriteBody(tokens, input);
|
||||
return toolWriteBody(tokens, input, mono);
|
||||
case 'Edit':
|
||||
case 'MultiEdit':
|
||||
return toolEditBody(tokens, input);
|
||||
return toolEditBody(tokens, input, mono);
|
||||
case 'Read':
|
||||
case 'Grep':
|
||||
case 'LS':
|
||||
return toolReadLikeBody(tokens, toolName, input);
|
||||
return toolReadLikeBody(tokens, toolName, input, mono);
|
||||
default:
|
||||
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(input), language: 'json');
|
||||
}
|
||||
@@ -522,21 +529,21 @@ Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
}
|
||||
|
||||
/// Write tool body: the file path + content with syntax highlighting.
|
||||
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] as String? ?? '';
|
||||
final content = input['content'] as String? ?? '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||
ClideCodeBlock(source: content, language: grammarForPath(path)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit / MultiEdit tool body: before/after diff view.
|
||||
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] as String? ?? '';
|
||||
final oldStr = input['old_string'] as String? ?? '';
|
||||
final newStr = input['new_string'] as String? ?? '';
|
||||
@@ -545,12 +552,12 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
||||
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||
const SizedBox(height: 4),
|
||||
ClideCodeBlock(source: oldStr, language: lang),
|
||||
const SizedBox(height: 8),
|
||||
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||
const SizedBox(height: 4),
|
||||
ClideCodeBlock(source: newStr, language: lang),
|
||||
],
|
||||
@@ -560,7 +567,7 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
/// Read / Grep / LS body: show the file path or pattern as a one-liner label
|
||||
/// so the card stays compact. These tools produce the interesting output in the
|
||||
/// result card rather than their input.
|
||||
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
||||
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? '';
|
||||
final extra = <String>[];
|
||||
if (toolName == 'Grep') {
|
||||
@@ -568,13 +575,13 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
|
||||
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
||||
}
|
||||
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground);
|
||||
}
|
||||
|
||||
/// A muted file path line, shared across tool bodies.
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path, String mono) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalTextMuted),
|
||||
);
|
||||
|
||||
// -- shared note / free-text field -------------------------------------------
|
||||
@@ -599,7 +606,7 @@ class _NoteFieldState extends State<_NoteField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Per-user defaults applied to NEW Claude sessions (T-457). Set from the
|
||||
/// Settings → Claude category; read by the pane when it spawns a session.
|
||||
/// Effort is applied at spawn (the `--effort` flag — there's no live
|
||||
/// set_effort); model and permission mode are sent as control requests right
|
||||
/// after the session starts.
|
||||
const String kDefaultModelKey = 'app.claude.defaultModel';
|
||||
const String kDefaultEffortKey = 'app.claude.defaultEffort';
|
||||
const String kDefaultPermissionModeKey = 'app.claude.defaultPermissionMode';
|
||||
|
||||
/// The default effort for new sessions, or null to let the CLI's own default
|
||||
/// stand. `'default'` is treated as "no override" too.
|
||||
String? defaultEffortFlag(SettingsStore settings) {
|
||||
final v = settings.get<String>(kDefaultEffortKey);
|
||||
if (v == null || v.isEmpty || v == 'default') return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Apply the model + permission-mode defaults to a freshly-spawned [session].
|
||||
/// A null/empty/`'default'` value is a no-op — the CLI's own default stands.
|
||||
/// (Effort is handled at spawn via [defaultEffortFlag], not here.)
|
||||
void applySessionDefaults(StreamJsonSession session, SettingsStore settings) {
|
||||
final model = settings.get<String>(kDefaultModelKey);
|
||||
if (model != null && model.isNotEmpty && model != 'default') session.setModel(model);
|
||||
final perm = settings.get<String>(kDefaultPermissionModeKey);
|
||||
if (perm != null && perm.isNotEmpty && perm != 'default') session.setPermissionMode(perm);
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -60,7 +59,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime;
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -55,7 +54,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -122,7 +122,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final messages = widget.model.messages;
|
||||
|
||||
return Column(
|
||||
@@ -291,7 +291,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final messages = widget.model.messages;
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -73,7 +73,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_members.isEmpty) return widget.lead;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -178,7 +178,7 @@ class _TeammateTile extends StatelessWidget {
|
||||
children: [
|
||||
Container(width: 3, height: 13, color: accent),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: clideMonoFamily),
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
|
||||
@@ -92,8 +92,8 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
final d = _decision;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a decision to view details.', muted: true));
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
final id = d['id'] as String? ?? '';
|
||||
@@ -148,19 +148,22 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (domain != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
|
||||
],
|
||||
),
|
||||
@@ -168,7 +171,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
@@ -201,7 +204,7 @@ class _RefCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: clideMonoFamily),
|
||||
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(refType, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
],
|
||||
@@ -227,7 +230,7 @@ class _StatusBadge extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_decisions.isEmpty) {
|
||||
@@ -153,7 +153,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final questions = filtered.where((d) => d.type == 'question').toList();
|
||||
final rejected = filtered.where((d) => d.type == 'rejected').toList();
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
@@ -309,9 +309,10 @@ class _DecisionCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (entry.domain != null) ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (entry.domain != null)
|
||||
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -321,7 +322,7 @@ class _DecisionCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: clideMonoFamily),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ library;
|
||||
import 'package:clide/builtin/deeplink/src/deep_link.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -68,7 +67,7 @@ class _DeepLinkConfirmDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return ClideSurface(
|
||||
width: 440,
|
||||
color: t.modalSurfaceBackground,
|
||||
@@ -83,7 +82,7 @@ class _DeepLinkConfirmDialog extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
ClideText('A clide:// link from outside the app is asking to:', muted: true, fontSize: clideFontSmall),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(action.describe, fontFamily: clideMonoFamily, fontSize: clideFontSmall, color: t.globalForeground),
|
||||
ClideText(action.describe, fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontSmall, color: t.globalForeground),
|
||||
const SizedBox(height: 8),
|
||||
ClideText('Only allow this if you trust where the link came from.', fontSize: clideFontMeta, color: t.statusWarning),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
@@ -86,7 +86,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'diff view',
|
||||
container: true,
|
||||
@@ -136,7 +136,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
@@ -180,7 +180,7 @@ class _FileDiff extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final path = diff['path'] as String? ?? '';
|
||||
final isBinary = diff['binary'] as bool? ?? false;
|
||||
final isNew = diff['new'] as bool? ?? false;
|
||||
@@ -250,7 +250,7 @@ class _HunkView extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
|
||||
],
|
||||
@@ -264,7 +264,7 @@ class _DiffLineRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final kind = line['kind'] as String? ?? 'context';
|
||||
final text = line['text'] as String? ?? '';
|
||||
final oldLineNo = line['oldLineNo'] as num?;
|
||||
@@ -294,7 +294,7 @@ class _DiffLineRow extends StatelessWidget {
|
||||
oldLineNo != null ? '${oldLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
@@ -305,15 +305,22 @@ class _DiffLineRow extends StatelessWidget {
|
||||
newLineNo != null ? '${newLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -311,7 +311,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
_text.tokens = tokens;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
|
||||
@@ -371,7 +371,12 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
|
||||
final style = TextStyle(
|
||||
color: foreground,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
);
|
||||
final editable = EditableText(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/extensions_notice.dart';
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import 'package:clide/builtin/extensions_ui/src/extensions_notice.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
/// Extensions settings tab (T-456). Built-in extensions are always on and there
|
||||
/// is no third-party install path yet, so the tab is a "watch this space"
|
||||
/// notice pointing at the records that track extension management (D-16 / T-8)
|
||||
/// rather than a toggle list. The real enable/install UI lands with third-party
|
||||
/// (Lua) extensions.
|
||||
class ExtensionsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.extensions-ui';
|
||||
@override
|
||||
String get title => 'Extensions UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
SettingsControlContribution(id: 'extensions-ui.notice-control', customId: 'extensions.notice', builder: (_) => const ExtensionsNotice()),
|
||||
const SettingsCategoryContribution(
|
||||
id: 'extensions',
|
||||
category: SettingsCategory(
|
||||
id: 'extensions',
|
||||
title: 'Extensions',
|
||||
iconName: 'puzzle-piece',
|
||||
priority: 80,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: '',
|
||||
fields: [SettingsField(key: 'app.extensions._notice', kind: SettingsFieldKind.custom, label: '', customId: 'extensions.notice')],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The Extensions settings tab's "watch this space" notice (T-456).
|
||||
///
|
||||
/// Built-in extensions are always on and there's no third-party install path
|
||||
/// yet, so there's nothing to manage. Rather than ship a toggle list that could
|
||||
/// brick the app, the tab explains that extension management arrives with
|
||||
/// third-party (Lua) extensions and points at the records that track it.
|
||||
/// Rendered inside the section card (no own surface) via the custom-control
|
||||
/// registry.
|
||||
class ExtensionsNotice extends StatelessWidget {
|
||||
const ExtensionsNotice({super.key});
|
||||
|
||||
static const ns = 'builtin.extensions-ui';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('puzzle-piece'), size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(
|
||||
i.string('notice.title', namespace: ns, placeholder: 'Extension management is coming'),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClideText(
|
||||
i.string(
|
||||
'notice.body',
|
||||
namespace: ns,
|
||||
placeholder:
|
||||
'Installing, enabling, and disabling extensions arrives with third-party (Lua) extension support. For now the built-in extensions are always on.',
|
||||
),
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClideText(
|
||||
i.string('notice.tracked', namespace: ns, placeholder: 'Tracked in T-8 (Tier 6) · D-16'),
|
||||
color: tokens.globalTextMuted,
|
||||
fontSize: clideFontCaption,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class _DirRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
@@ -263,7 +263,7 @@ class _Row extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final leftPadding = 8.0 + (depth * 14.0);
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
@@ -301,7 +301,7 @@ class _FilteredFileRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideTappable(
|
||||
onTap: () => openWorkspaceFile(ClideKernel.of(context), entry.path),
|
||||
builder: (context, hovered, _) => Container(
|
||||
|
||||
@@ -68,7 +68,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'git panel',
|
||||
container: true,
|
||||
@@ -143,7 +143,7 @@ class _BranchHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final branch = controller.branch ?? '(detached)';
|
||||
final parts = <String>[branch];
|
||||
if (controller.ahead > 0) parts.add('↑${controller.ahead}');
|
||||
@@ -173,7 +173,7 @@ class _CommitInput extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
@@ -239,7 +239,7 @@ class _FileGroup extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -271,7 +271,7 @@ class _GitFileRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final path = entry['path'] as String? ?? '';
|
||||
final name = path.split('/').last;
|
||||
final indexState = entry['indexState'] as String?;
|
||||
@@ -352,7 +352,7 @@ class _SmallAction extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticsLabel ?? label,
|
||||
@@ -376,7 +376,7 @@ class _DiscardConfirmDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final name = path.split('/').last;
|
||||
return Container(
|
||||
width: 360,
|
||||
|
||||
@@ -56,7 +56,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_branch == null) return const SizedBox.shrink();
|
||||
final parts = <String>[_branch!];
|
||||
if (_ahead > 0) parts.add('↑$_ahead');
|
||||
@@ -143,7 +143,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
@@ -164,7 +164,12 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Switch branch', fontSize: clideFontCaption, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
child: ClideText(
|
||||
'Switch branch',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => widget.onDismiss(),
|
||||
@@ -210,7 +215,7 @@ class _BranchRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
@@ -229,7 +234,7 @@ class _BranchRow extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontSize: clideFontMono,
|
||||
color: current ? tokens.globalForeground : tokens.listItemForeground,
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ class ToolStatusItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.toolchain,
|
||||
builder: (ctx, _) {
|
||||
|
||||
@@ -42,5 +42,39 @@ class KeybindingsUiExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {'preset': entry.key});
|
||||
},
|
||||
),
|
||||
// Keymap settings category (T-451). The select reads the active preset
|
||||
// from kKeymapPresetSetting; picking one runs `keymap.preset.<value>`
|
||||
// (applyCommandPrefix), which calls KeymapService.setPreset — persisting
|
||||
// and reloading the layered keymap live.
|
||||
const SettingsCategoryContribution(
|
||||
id: 'keymap',
|
||||
category: SettingsCategory(
|
||||
id: 'keymap',
|
||||
title: 'Keymap',
|
||||
iconName: 'keyboard',
|
||||
priority: 20,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Preset',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kKeymapPresetSetting,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Active preset',
|
||||
help: 'Keyboard layout for the whole app.',
|
||||
defaultValue: 'default',
|
||||
applyCommandPrefix: 'keymap.preset.',
|
||||
options: [
|
||||
SettingsOption(value: 'default', label: 'Default'),
|
||||
SettingsOption(value: 'vim', label: 'Vim'),
|
||||
SettingsOption(value: 'vscode', label: 'VS Code'),
|
||||
SettingsOption(value: 'jetbrains', label: 'JetBrains'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class AboutDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 480,
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -67,7 +67,7 @@ class _Kv extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
child: ClideText(value, fontSize: 13, fontFamily: ClideSettings.fonts.monoOf(context), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -106,7 +106,7 @@ class _Licenses extends StatelessWidget {
|
||||
child: ClideText(
|
||||
'${d.name} ${d.version} · ${d.license}',
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -75,6 +75,8 @@ List<TopMenu> buildClideMenuTree() => [
|
||||
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
|
||||
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
|
||||
const MenuSeparator(),
|
||||
const MenuCommandItem('settings.open', fallbackTitle: 'Settings…'),
|
||||
const MenuSeparator(),
|
||||
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -110,7 +110,7 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -137,7 +137,12 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 14,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
@@ -168,7 +173,7 @@ class NotARepoDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
||||
@@ -142,7 +142,7 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final open = widget.controller.openIndex == widget.index;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
@@ -162,7 +162,12 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
|
||||
label: node.title,
|
||||
enabled: node.enabled,
|
||||
trailing: node.keybinding != null
|
||||
? ClideText(node.keybinding!, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: ClideTheme.of(ctx).surface.globalTextMuted)
|
||||
? ClideText(
|
||||
node.keybinding!,
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: ClideSettings.theme.of(ctx).surface.globalTextMuted,
|
||||
)
|
||||
: null,
|
||||
onSelect: () => unawaited(widget.kernel.commands.execute(node.commandId)),
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ class _DockStatusItemState extends State<DockStatusItem> {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
final tokens = ClideSettings.theme.of(ctx).surface;
|
||||
final open = kernel.arrangement.isVisible(Slots.dock);
|
||||
final errors = kernel.logRing.countAtLeast(LogLevel.error);
|
||||
final warns = kernel.logRing.countAtLeast(LogLevel.warn) - errors;
|
||||
|
||||
@@ -75,7 +75,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
return ListenableBuilder(
|
||||
listenable: _c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final rows = _c.filtered;
|
||||
return Semantics(
|
||||
label: 'output log',
|
||||
@@ -149,7 +149,7 @@ class _Chip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
@@ -177,7 +177,7 @@ class _JumpPill extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'jump to latest',
|
||||
@@ -206,7 +206,7 @@ class _LogRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final fg = _levelColor(record.level, tokens);
|
||||
final t = record.timestamp;
|
||||
final hh = t.hour.toString().padLeft(2, '0');
|
||||
@@ -217,11 +217,11 @@ class _LogRow extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('$hh:$mm:$ss', fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText('$hh:$mm:$ss', fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: ClideText(record.level.name.toUpperCase(), fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
child: ClideText(record.level.name.toUpperCase(), fontSize: clideFontMono, color: fg, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -230,14 +230,14 @@ class _LogRow extends StatelessWidget {
|
||||
record.source,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(record.message, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
child: ClideText(record.message, fontSize: clideFontMono, color: fg, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -40,7 +40,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (c.activePath == null) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class _LinkRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final target = link[pathKey] as String? ?? '';
|
||||
final alias = link['alias'] as String?;
|
||||
final display = alias ?? target;
|
||||
|
||||
@@ -97,7 +97,7 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -174,7 +174,7 @@ class _SearchResultRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final path = entry['path'] as String? ?? '';
|
||||
final score = (entry['score'] as num?)?.toDouble() ?? 0;
|
||||
return Padding(
|
||||
@@ -228,7 +228,7 @@ class _ScoreBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('${(score * 100).round()}%', fontSize: clideFontBadge, muted: true, fontFamily: clideMonoFamily),
|
||||
ClideText('${(score * 100).round()}%', fontSize: clideFontBadge, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -242,7 +242,7 @@ class _FileRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final path = entry['path'] as String? ?? '';
|
||||
return Padding(
|
||||
key: focusKey,
|
||||
@@ -269,7 +269,7 @@ class _QueryResultRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
|
||||
final values = entry.entries.where((e) => e.key != 'name' && e.key != 'path').map((e) => '${e.key}: ${e.value}').join(' · ');
|
||||
return Padding(
|
||||
|
||||
@@ -42,7 +42,7 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'problems panel',
|
||||
container: true,
|
||||
@@ -104,7 +104,7 @@ class _ProblemRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
@@ -113,7 +113,7 @@ class _ProblemRow extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(problem.source, fontSize: clideFontMono, color: tokens.statusWarning, fontFamily: clideMonoFamily),
|
||||
ClideText(problem.source, fontSize: clideFontMono, color: tokens.statusWarning, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: ClideText(problem.message, color: tokens.sidebarForeground, maxLines: 2)),
|
||||
],
|
||||
@@ -121,7 +121,7 @@ class _ProblemRow extends StatelessWidget {
|
||||
if (problem.hint != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 44, top: 2),
|
||||
child: ClideText(problem.hint!, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
child: ClideText(problem.hint!, fontSize: clideFontMono, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -66,7 +66,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -90,7 +90,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
}
|
||||
|
||||
Widget _findBody() {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final c = _controller!;
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
@@ -255,7 +255,7 @@ class _Toggle extends StatelessWidget {
|
||||
),
|
||||
child: ClideText(
|
||||
label,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontSize: clideFontCaption,
|
||||
color: active ? tokens.listItemSelectedForeground : tokens.sidebarForeground,
|
||||
),
|
||||
@@ -344,9 +344,9 @@ class _MatchRow extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted),
|
||||
),
|
||||
Expanded(child: replacement.isEmpty ? _highlighted() : _preview()),
|
||||
Expanded(child: replacement.isEmpty ? _highlighted(ClideSettings.fonts.monoOf(context)) : _preview(ClideSettings.fonts.monoOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -354,9 +354,9 @@ class _MatchRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle get _base => TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground);
|
||||
TextStyle _base(String mono) => TextStyle(fontFamily: mono, fontSize: clideFontCaption, color: tokens.sidebarForeground);
|
||||
|
||||
Widget _highlighted() {
|
||||
Widget _highlighted(String mono) {
|
||||
final line = match.preview;
|
||||
final start = match.matchStart.clamp(0, line.length);
|
||||
final end = match.matchEnd.clamp(start, line.length);
|
||||
@@ -364,12 +364,12 @@ class _MatchRow extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
style: _base,
|
||||
style: _base(mono),
|
||||
children: [
|
||||
TextSpan(text: line.substring(0, start)),
|
||||
TextSpan(
|
||||
text: line.substring(start, end),
|
||||
style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold),
|
||||
style: _base(mono).copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(text: line.substring(end)),
|
||||
],
|
||||
@@ -379,7 +379,7 @@ class _MatchRow extends StatelessWidget {
|
||||
|
||||
/// Replace-preview: the original line struck through, then the
|
||||
/// rewritten line (computed with the same engine the apply uses).
|
||||
Widget _preview() {
|
||||
Widget _preview(String mono) {
|
||||
final after = applyToText(match.preview, query, replacement).text;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -389,7 +389,7 @@ class _MatchRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
text: match.preview,
|
||||
style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
style: _base(mono).copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
RichText(
|
||||
@@ -397,7 +397,7 @@ class _MatchRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
text: after,
|
||||
style: _base.copyWith(color: tokens.globalFocus),
|
||||
style: _base(mono).copyWith(color: tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -443,7 +443,7 @@ class _MessageDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return _DialogFrame(
|
||||
tokens: tokens,
|
||||
children: [
|
||||
@@ -468,7 +468,7 @@ class _ConfirmDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return _DialogFrame(
|
||||
tokens: tokens,
|
||||
children: [
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/settings_category_view.dart';
|
||||
export 'src/settings_modal.dart';
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import 'package:clide/builtin/settings_ui/src/settings_modal.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
/// Schema-driven Settings UI (epic T-444). T-445 lands the foundation: the
|
||||
/// `settings.open` command and the modal shell it opens. The category rail
|
||||
/// (T-447), schema field renderer (T-448), scope tags (T-449), search
|
||||
/// (T-450) and the per-subsystem categories fill the shell in later tickets.
|
||||
class SettingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.settings-ui';
|
||||
@override
|
||||
String get title => 'Settings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
// Opens the Settings panel. Palette + File-menu entry come for free off
|
||||
// the title; ctrl+, is the conventional settings shortcut.
|
||||
CommandContribution(id: 'settings.open', command: 'settings.open', title: 'Settings…', defaultBinding: 'ctrl+,', run: _open),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _open(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'settings-ui not activated'),
|
||||
);
|
||||
}
|
||||
await ctx.dialog.show<Object>((context, dismiss) => SettingsModal(onDismiss: () => dismiss()));
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// i18n namespace shared with the settings modal shell.
|
||||
const _settingsNs = 'builtin.settings-ui';
|
||||
|
||||
/// The schema-driven field renderer (T-448, epic T-444): turns a
|
||||
/// [SettingsCategory] into carded sections of field rows. Each field binds to a
|
||||
/// `SettingsStore` key — read with `get` (falling back to the schema default),
|
||||
/// written with `set` on edit. Rebuilds live as the store changes.
|
||||
///
|
||||
/// Each row ends in a per-field scope tag (T-449) whose menu also resets the
|
||||
/// value; cross-category search lives in [SettingsSearchResults] (T-450).
|
||||
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
|
||||
class SettingsCategoryView extends StatelessWidget {
|
||||
const SettingsCategoryView({super.key, required this.category});
|
||||
|
||||
final SettingsCategory category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ClideKernel.of(context).settings;
|
||||
// Re-read on every settings change so edits (and resets) reflect at once.
|
||||
return ListenableBuilder(
|
||||
listenable: store,
|
||||
builder: (context, _) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [for (final section in category.sections) _SectionCard(section: section, store: store)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when [field] matches [query] (case-insensitive; label or help). An
|
||||
/// empty query matches everything.
|
||||
bool settingsFieldMatches(SettingsField field, String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
return field.label.toLowerCase().contains(q) || (field.help?.toLowerCase().contains(q) ?? false);
|
||||
}
|
||||
|
||||
/// [category]'s sections holding only the fields matching [query]; empty
|
||||
/// sections are dropped.
|
||||
List<SettingsSection> settingsMatchingSections(SettingsCategory category, String query) {
|
||||
final out = <SettingsSection>[];
|
||||
for (final s in category.sections) {
|
||||
final fields = s.fields.where((f) => settingsFieldMatches(f, query)).toList();
|
||||
if (fields.isNotEmpty) out.add(SettingsSection(label: s.label, fields: fields));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// How many fields in [category] match [query] (the rail's per-category count).
|
||||
int settingsMatchCount(SettingsCategory category, String query) =>
|
||||
category.sections.fold(0, (n, s) => n + s.fields.where((f) => settingsFieldMatches(f, query)).length);
|
||||
|
||||
/// Cross-category search results (T-450): every matching field across ALL
|
||||
/// registered categories, grouped under a category subheader, rendered with the
|
||||
/// same carded field rows and editable inline.
|
||||
class SettingsSearchResults extends StatelessWidget {
|
||||
const SettingsSearchResults({super.key, required this.query});
|
||||
|
||||
final String query;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
final store = ClideKernel.of(context).settings;
|
||||
final registry = ClideKernel.of(context).settingsRegistry;
|
||||
return ListenableBuilder(
|
||||
listenable: store,
|
||||
builder: (context, _) {
|
||||
final blocks = <Widget>[];
|
||||
for (final c in registry.categories) {
|
||||
final sections = settingsMatchingSections(c, query);
|
||||
if (sections.isEmpty) continue;
|
||||
blocks.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: blocks.isEmpty ? 0 : 14, bottom: 8),
|
||||
child: ClideText(c.title, fontSize: 13, fontWeight: FontWeight.w600, color: tokens.globalForeground),
|
||||
),
|
||||
);
|
||||
for (final s in sections) {
|
||||
blocks.add(_SectionCard(section: s, store: store));
|
||||
}
|
||||
}
|
||||
if (blocks.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ClideText(
|
||||
i.string('search.empty', namespace: _settingsNs, placeholder: 'No settings match your search.'),
|
||||
color: tokens.globalTextMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One section: a small-caps header above an elevated card of field rows.
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({required this.section, required this.store});
|
||||
|
||||
final SettingsSection section;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (section.label.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: ClideText(
|
||||
section.label.toUpperCase(),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
),
|
||||
ClideSurface(
|
||||
// Card surface (surface.md): panelHeader resolves to the `surface`
|
||||
// palette key (the elevated card tone); inputs inside recede to
|
||||
// panelBackground.
|
||||
color: tokens.panelHeader,
|
||||
border: tokens.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < section.fields.length; i++) ...[if (i > 0) const ClideDivider(), _FieldRow(field: section.fields[i], store: store)],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label/help block + the field's control + a reset affordance.
|
||||
class _FieldRow extends StatelessWidget {
|
||||
const _FieldRow({required this.field, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final label = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(field.label, color: tokens.globalForeground),
|
||||
if (field.help != null && field.help!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: ClideText(field.help!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Custom controls own their layout, persistence, and scope — render them
|
||||
// full-width under the label rather than in the narrow control slot.
|
||||
if (field.kind == SettingsFieldKind.custom) {
|
||||
final id = field.customId;
|
||||
final builder = id == null ? null : ClideKernel.of(context).settingsControlRegistry.builderFor(id);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (field.label.isNotEmpty) ...[label, const SizedBox(height: 10)],
|
||||
builder?.call(context) ?? const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final raw = store.get<Object>(field.key);
|
||||
final effective = raw ?? field.defaultValue;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: label),
|
||||
const SizedBox(width: 16),
|
||||
_Control(field: field, value: effective, store: store),
|
||||
const SizedBox(width: 10),
|
||||
_ScopeTag(field: field, store: store, effectiveValue: effective),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches to the control widget for the field's [SettingsFieldKind].
|
||||
class _Control extends StatelessWidget {
|
||||
const _Control({required this.field, required this.value, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final Object? value;
|
||||
final SettingsStore store;
|
||||
|
||||
void _set(Object? v) => store.set<Object?>(field.key, v);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (field.kind) {
|
||||
case SettingsFieldKind.toggle:
|
||||
return _ToggleControl(checked: value == true, onChanged: _set);
|
||||
case SettingsFieldKind.select:
|
||||
return _SelectControl(
|
||||
field: field,
|
||||
value: value?.toString(),
|
||||
onPick: (v) {
|
||||
final prefix = field.applyCommandPrefix;
|
||||
if (prefix != null) {
|
||||
// Value selects a command (the subsystem applies + persists).
|
||||
ClideKernel.of(context).commands.execute('$prefix$v');
|
||||
} else {
|
||||
_set(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
case SettingsFieldKind.text:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, onCommit: _set);
|
||||
case SettingsFieldKind.number:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set);
|
||||
case SettingsFieldKind.file:
|
||||
return _FileControl(field: field);
|
||||
case SettingsFieldKind.custom:
|
||||
// Custom fields are rendered full-width by _FieldRow; never here.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox toggle (mirrors the theme-picker high-contrast box).
|
||||
class _ToggleControl extends StatelessWidget {
|
||||
const _ToggleControl({required this.checked, required this.onChanged});
|
||||
|
||||
final bool checked;
|
||||
final void Function(bool value) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: checked,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => onChanged(!checked),
|
||||
builder: (ctx, hovered, pressed) => Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tokens.buttonBackground : (hovered ? tokens.listItemHoverBackground : null),
|
||||
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: checked ? ClideIcon(const CheckIcon(), size: 11, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum picker — current value on an anchored popover of options.
|
||||
class _SelectControl extends StatefulWidget {
|
||||
const _SelectControl({required this.field, required this.value, required this.onPick});
|
||||
|
||||
final SettingsField field;
|
||||
final String? value;
|
||||
final void Function(String value) onPick;
|
||||
|
||||
@override
|
||||
State<_SelectControl> createState() => _SelectControlState();
|
||||
}
|
||||
|
||||
class _SelectControlState extends State<_SelectControl> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _label {
|
||||
for (final o in widget.field.options) {
|
||||
if (o.value == widget.value) return o.label;
|
||||
}
|
||||
return widget.value ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.field.options)
|
||||
ClideMenuItem(
|
||||
label: o.label,
|
||||
active: o.value == widget.value,
|
||||
semanticLabel: '${widget.field.label}: ${o.label}',
|
||||
onSelect: () => widget.onPick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.field.label}: $_label. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(_label, color: tokens.globalForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline text / numeric editor. Commits on Enter and on focus loss; numeric
|
||||
/// fields parse + clamp to the field's bounds, ignoring unparseable input.
|
||||
class _EditControl extends StatefulWidget {
|
||||
const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit});
|
||||
|
||||
final SettingsField field;
|
||||
final String value;
|
||||
final bool numeric;
|
||||
final void Function(Object value) onCommit;
|
||||
|
||||
@override
|
||||
State<_EditControl> createState() => _EditControlState();
|
||||
}
|
||||
|
||||
class _EditControlState extends State<_EditControl> {
|
||||
late final TextEditingController _controller = TextEditingController(text: widget.value);
|
||||
late final FocusNode _focus = FocusNode(debugLabel: 'settings-${widget.field.key}');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() {
|
||||
if (!_focus.hasFocus) _commit();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_EditControl old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Reflect external changes (reset, scope flip) only when not being edited.
|
||||
if (!_focus.hasFocus && widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final text = _controller.text.trim();
|
||||
if (widget.numeric) {
|
||||
final n = num.tryParse(text);
|
||||
if (n == null) {
|
||||
_controller.text = widget.value; // revert unparseable input
|
||||
return;
|
||||
}
|
||||
var clamped = n;
|
||||
final min = widget.field.min;
|
||||
final max = widget.field.max;
|
||||
if (min != null && clamped < min) clamped = min;
|
||||
if (max != null && clamped > max) clamped = max;
|
||||
// Preserve int vs double per the parsed text.
|
||||
final out = clamped == clamped.roundToDouble() && !text.contains('.') ? clamped.toInt() : clamped;
|
||||
_controller.text = '$out';
|
||||
widget.onCommit(out);
|
||||
} else {
|
||||
widget.onCommit(text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
textField: true,
|
||||
label: widget.field.label,
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
width: widget.numeric ? 88 : 180,
|
||||
height: 26,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontMono, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Opens external file" affordance — a button that runs the field's command.
|
||||
class _FileControl extends StatelessWidget {
|
||||
const _FileControl({required this.field});
|
||||
|
||||
final SettingsField field;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commands = ClideKernel.of(context).commands;
|
||||
return ClideButton(
|
||||
label: field.label,
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-field scope tag (T-449): a colour-coded glyph showing where the value
|
||||
/// lives — folder = Project (`.clide`), globe = Always (`~/.clide`),
|
||||
/// circle-dashed = Default/unset. Tapping opens a menu to move the value
|
||||
/// between the scopes the key supports, or reset it to default.
|
||||
///
|
||||
/// Storage layering follows the SettingsStore key prefix: `ext.*` keys may
|
||||
/// live in either file (project overrides app); `app.*`/`project.*` keys live
|
||||
/// only in their prefix's layer, so their menu offers that one scope + reset.
|
||||
class _ScopeTag extends StatefulWidget {
|
||||
const _ScopeTag({required this.field, required this.store, required this.effectiveValue});
|
||||
|
||||
final SettingsField field;
|
||||
final SettingsStore store;
|
||||
final Object? effectiveValue;
|
||||
|
||||
@override
|
||||
State<_ScopeTag> createState() => _ScopeTagState();
|
||||
}
|
||||
|
||||
class _ScopeTagState extends State<_ScopeTag> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _ns(String key, String fallback) => ClideSettings.i18n.of(context).string(key, namespace: _settingsNs, placeholder: fallback);
|
||||
|
||||
({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return switch (layer) {
|
||||
SettingsScope.project => (
|
||||
glyph: 'folder',
|
||||
color: tokens.statusSuccess,
|
||||
tip: _ns('scope.tip.project', 'Stored in this project (.clide)'),
|
||||
label: _ns('scope.project', 'This project'),
|
||||
),
|
||||
SettingsScope.app => (
|
||||
glyph: 'globe',
|
||||
color: tokens.statusWarning,
|
||||
tip: _ns('scope.tip.always', 'Stored for all clide (~/.clide)'),
|
||||
label: _ns('scope.always', 'All clide'),
|
||||
),
|
||||
_ => (
|
||||
glyph: 'circle-dashed',
|
||||
color: tokens.globalTextMuted,
|
||||
tip: _ns('scope.tip.default', 'Unset — using the default'),
|
||||
label: _ns('scope.default', 'Default'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Move the value into [layer], clearing it from the key's other layers.
|
||||
void _moveTo(SettingsScope layer) {
|
||||
final value = widget.store.get<Object>(widget.field.key) ?? widget.field.defaultValue;
|
||||
widget.store.setAt(layer, widget.field.key, value);
|
||||
for (final other in widget.store.writableLayers(widget.field.key)) {
|
||||
if (other != layer) widget.store.removeAt(other, widget.field.key);
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
for (final layer in widget.store.writableLayers(widget.field.key)) {
|
||||
widget.store.removeAt(layer, widget.field.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = widget.store.effectiveLayer(widget.field.key);
|
||||
final look = _appearance(current);
|
||||
final layers = widget.store.writableLayers(widget.field.key);
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final layer in layers) ClideMenuItem(label: _appearance(layer).label, active: layer == current, onSelect: () => _moveTo(layer)),
|
||||
const ClideMenuSeparator(),
|
||||
ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.field.label} scope: ${look.label}',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
tooltip: look.tip,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ClideIcon(PhosphorIcons.byName(look.glyph), size: 15, color: look.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:clide/builtin/settings_ui/src/settings_category_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The schema-driven Settings panel shell (T-445, epic T-444).
|
||||
///
|
||||
/// A centered modal over the dimmed app (hosted by [DialogHost] via
|
||||
/// `ctx.dialog.show`), built from the `modalSurface*` tokens (D-7, no
|
||||
/// Material). It frames the two regions the epic fills in:
|
||||
///
|
||||
/// - the **category rail** on the left (interactive navigation lands in
|
||||
/// T-447; the list is data-driven from the registered schemas), and
|
||||
/// - the **scrolling carded panel** on the right (the schema field renderer
|
||||
/// is [SettingsCategoryView], T-448).
|
||||
///
|
||||
/// Categories come from the kernel [SettingsRegistry]; until one registers a
|
||||
/// schema the panel shows its empty state. Dismiss with the close button, Esc,
|
||||
/// or a barrier tap (the last handled by [DialogHost]).
|
||||
///
|
||||
/// Wireframe: `docs/design/wireframes/settings/settings-screen.png`.
|
||||
class SettingsModal extends StatefulWidget {
|
||||
const SettingsModal({super.key, required this.onDismiss});
|
||||
|
||||
/// Closes the modal. Wired to the dialog router's `dismiss` by the
|
||||
/// opener (see `SettingsUiExtension`).
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
static const ns = 'builtin.settings-ui';
|
||||
|
||||
// The modal is a fixed-size desktop surface (the app is a desktop host);
|
||||
// the right panel scrolls when its cards exceed the height (surface.md).
|
||||
static const double _width = 760;
|
||||
static const double _height = 560;
|
||||
static const double _railWidth = 196;
|
||||
|
||||
@override
|
||||
State<SettingsModal> createState() => _SettingsModalState();
|
||||
}
|
||||
|
||||
class _SettingsModalState extends State<SettingsModal> {
|
||||
/// Selected category id; null falls back to the first registered category.
|
||||
/// The rail sets this in T-447.
|
||||
String? _selectedId;
|
||||
|
||||
/// Cross-category search query (T-450); non-empty swaps the panel to results.
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final title = i.string('modal.title', namespace: SettingsModal.ns, placeholder: 'Settings');
|
||||
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: Semantics(
|
||||
container: true,
|
||||
label: title,
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: SettingsModal._width,
|
||||
height: SettingsModal._height,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(title: title, onClose: widget.onDismiss),
|
||||
const ClideDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: SettingsModal._railWidth,
|
||||
child: _CategoryRail(
|
||||
selectedId: _selectedId,
|
||||
query: _query,
|
||||
onSelect: (id) => setState(() => _selectedId = id),
|
||||
onQueryChanged: (q) => setState(() => _query = q),
|
||||
),
|
||||
),
|
||||
const ClideDivider(axis: Axis.vertical),
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: _query.trim().isEmpty ? _SettingsPanel(selectedId: _selectedId) : SettingsSearchResults(query: _query),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title bar: gear glyph + "Settings" on the left, close ✕ on the right.
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.title, required this.onClose});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(const GearIcon(), size: 16, color: tokens.globalForeground),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText(title, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
_CloseButton(onTap: onClose),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseButton extends StatelessWidget {
|
||||
const _CloseButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
final label = i.string('modal.close', namespace: SettingsModal.ns, placeholder: 'Close');
|
||||
final hint = i.string('modal.close.hint', namespace: SettingsModal.ns, placeholder: 'Close settings');
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
hint: hint,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, pressed) => Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Left rail — a cross-category search box atop the registered categories,
|
||||
/// data-driven from the [SettingsRegistry]. Selecting one swaps the panel
|
||||
/// (T-447); while searching, each row shows its match count and zero-match
|
||||
/// rows dim (T-450).
|
||||
class _CategoryRail extends StatelessWidget {
|
||||
const _CategoryRail({required this.selectedId, required this.query, required this.onSelect, required this.onQueryChanged});
|
||||
|
||||
/// The modal's chosen category id (null → the first category).
|
||||
final String? selectedId;
|
||||
final String query;
|
||||
final void Function(String id) onSelect;
|
||||
final ValueChanged<String> onQueryChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
final registry = ClideKernel.of(context).settingsRegistry;
|
||||
final searching = query.trim().isNotEmpty;
|
||||
return ListenableBuilder(
|
||||
listenable: registry,
|
||||
builder: (context, _) {
|
||||
final categories = registry.categories;
|
||||
final effectiveId = selectedId ?? (categories.isEmpty ? null : categories.first.id);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: ClideFilterBox(
|
||||
hint: i.string('search.hint', namespace: SettingsModal.ns, placeholder: 'Search settings…'),
|
||||
onChanged: onQueryChanged,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 12, 6),
|
||||
child: ClideText(
|
||||
i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final c in categories)
|
||||
_RailRow(
|
||||
category: c,
|
||||
selected: !searching && c.id == effectiveId,
|
||||
matchCount: searching ? settingsMatchCount(c, query) : null,
|
||||
onTap: () => onSelect(c.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One category row: optional glyph + title, accent left-stripe + surfaceHi
|
||||
/// fill when selected (surface.md side panels). When [matchCount] is non-null
|
||||
/// (searching) it shows the count and dims to muted on zero matches (T-450).
|
||||
class _RailRow extends StatelessWidget {
|
||||
const _RailRow({required this.category, required this.selected, required this.matchCount, required this.onTap});
|
||||
|
||||
final SettingsCategory category;
|
||||
final bool selected;
|
||||
final int? matchCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final dim = matchCount == 0;
|
||||
final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground);
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: category.title,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
// color: null => no paint (shows the modal surface behind the rail).
|
||||
color: selected ? tokens.sidebarItemSelected : (hovered ? tokens.sidebarItemHover : null),
|
||||
child: Row(
|
||||
children: [
|
||||
// Accent left-stripe; the 2px slot is reserved either way so the
|
||||
// row never shifts. Painted only when selected — no color literal.
|
||||
SizedBox(width: 2, child: selected ? ColoredBox(color: tokens.panelActiveBorder) : null),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||||
child: Row(
|
||||
children: [
|
||||
if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)],
|
||||
Expanded(
|
||||
child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (matchCount != null) ClideText('$matchCount', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Right panel — renders the selected category's schema (or the first
|
||||
/// registered one) via [SettingsCategoryView]; the empty state shows when no
|
||||
/// category is registered. The region recedes to panelBackground (set by the
|
||||
/// modal) so the panelHeader cards pop (surface.md).
|
||||
class _SettingsPanel extends StatelessWidget {
|
||||
const _SettingsPanel({required this.selectedId});
|
||||
|
||||
final String? selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registry = ClideKernel.of(context).settingsRegistry;
|
||||
return ListenableBuilder(
|
||||
listenable: registry,
|
||||
builder: (context, _) {
|
||||
final categories = registry.categories;
|
||||
if (categories.isEmpty) return const _EmptyState();
|
||||
final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first;
|
||||
return SettingsCategoryView(category: selected);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ClideText(
|
||||
i.string('panel.empty', namespace: SettingsModal.ns, placeholder: 'No settings categories are registered yet.'),
|
||||
color: tokens.globalTextMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class ReaderActionBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -90,7 +90,7 @@ class ReaderPinButton extends StatelessWidget {
|
||||
enabled: onTap != null,
|
||||
active: pinned,
|
||||
onTap: onTap,
|
||||
tokens: ClideTheme.of(context).surface,
|
||||
tokens: ClideSettings.theme.of(context).surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The Appearance category's custom theme control (T-452) — the one bespoke
|
||||
/// settings control the schema engine defers to (registered as
|
||||
/// `SettingsControlContribution('theme.picker')`).
|
||||
///
|
||||
/// Base themes render as selectable chips; a High-contrast toggle maps to the
|
||||
/// `-hc` sibling (D-69) via the shared [theme_families] helpers. Selecting
|
||||
/// applies live through [ThemeController] (which persists via theme_persistence)
|
||||
/// — so this control owns its own apply + scope, unlike schema-keyed fields.
|
||||
class AppearanceThemeControl extends StatelessWidget {
|
||||
const AppearanceThemeControl({super.key});
|
||||
|
||||
static const ns = 'builtin.theme-picker';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = ClideKernel.of(context).theme;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final themes = baseThemes(controller.available);
|
||||
final currentBase = baseThemeName(controller.currentName);
|
||||
final hc = isHcName(controller.currentName);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final t in themes)
|
||||
_ThemeChip(
|
||||
label: t.displayName,
|
||||
selected: t.name == currentBase,
|
||||
onTap: () => controller.select(resolveThemeName(controller.available, t.name, highContrast: hc)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HcToggle(
|
||||
checked: hc,
|
||||
label: i.string('toggle.highContrast', namespace: ns, placeholder: 'High contrast'),
|
||||
onTap: () => controller.select(resolveThemeName(controller.available, currentBase, highContrast: !hc)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeChip extends StatelessWidget {
|
||||
const _ThemeChip({required this.label, required this.selected, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? tokens.buttonActiveBackground : (hovered ? tokens.listItemHoverBackground : tokens.panelBackground),
|
||||
border: Border.all(color: selected ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: ClideText(label, color: selected ? tokens.globalBackground : tokens.globalForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HcToggle extends StatelessWidget {
|
||||
const _HcToggle({required this.checked, required this.label, required this.onTap});
|
||||
|
||||
final bool checked;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: checked,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tokens.buttonBackground : (hovered ? tokens.listItemHoverBackground : null),
|
||||
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: checked ? ClideIcon(const CheckIcon(), size: 11, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(label, color: tokens.listItemForeground),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/theme_picker/src/appearance_control.dart';
|
||||
import 'package:clide/builtin/theme_picker/src/settings_view.dart';
|
||||
import 'package:clide/builtin/theme_picker/src/theme_status_item.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart' show kMonoFontSettingKey, kUiFontSettingKey;
|
||||
|
||||
class ThemePickerExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -20,14 +23,69 @@ class ThemePickerExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
// Opens the settings modal (T-238). Command id kept as `theme.pick`
|
||||
// (the welcome theme-link and other callers reference it); ⌘K opens
|
||||
// Settings, whose only section today is the theme picker.
|
||||
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick),
|
||||
// Opens the theme picker modal (T-238). Command id kept as `theme.pick`
|
||||
// (the welcome theme-link and other callers reference it). Titled
|
||||
// "Theme…" to disambiguate from the schema-driven Settings panel's
|
||||
// `settings.open` (T-444); the theme picker folds into that panel's
|
||||
// Appearance category in T-452.
|
||||
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Theme…', defaultBinding: 'ctrl+k', run: _pick),
|
||||
// Always-visible switcher in the far-right status bar (T-234).
|
||||
// priority >= 100 places it in the right group; registered after
|
||||
// ipc-status so it sits to its right.
|
||||
StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
|
||||
// Appearance settings category (T-452) — the theme picker as the schema
|
||||
// engine's one custom control, registered against the control registry.
|
||||
SettingsControlContribution(id: 'theme-picker.appearance-control', customId: 'theme.picker', builder: (_) => const AppearanceThemeControl()),
|
||||
const SettingsCategoryContribution(
|
||||
id: 'appearance',
|
||||
category: SettingsCategory(
|
||||
id: 'appearance',
|
||||
title: 'Appearance',
|
||||
iconName: 'palette',
|
||||
priority: 10,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Theme',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: 'app.theme',
|
||||
kind: SettingsFieldKind.custom,
|
||||
label: 'Theme',
|
||||
help: 'Color theme; high contrast switches to the accessible variant.',
|
||||
customId: 'theme.picker',
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
label: 'Typography',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kUiFontSettingKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'UI font',
|
||||
help: 'Typeface for the app interface; applies live.',
|
||||
defaultValue: 'Inter',
|
||||
options: [
|
||||
SettingsOption(value: 'Inter', label: 'Inter'),
|
||||
SettingsOption(value: 'JosefinSans', label: 'Josefin Sans'),
|
||||
],
|
||||
),
|
||||
SettingsField(
|
||||
key: kMonoFontSettingKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Monospace font',
|
||||
help: 'Terminal, diffs, code, and IDs; applies live.',
|
||||
defaultValue: 'JetBrainsMono',
|
||||
options: [
|
||||
SettingsOption(value: 'JetBrainsMono', label: 'JetBrains Mono'),
|
||||
SettingsOption(value: 'FiraMono', label: 'Fira Mono'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _pick(List<String> args) async {
|
||||
|
||||
@@ -45,9 +45,8 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final i = kernel.i18n;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
final themes = baseThemes(widget.controller.available);
|
||||
final currentBase = baseThemeName(widget.controller.currentName);
|
||||
final title = i.string('modal.title', namespace: SettingsView.ns, placeholder: 'Settings');
|
||||
@@ -75,7 +74,7 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
i.string('section.appearance', namespace: SettingsView.ns, placeholder: 'Appearance'),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_HighContrastToggle(
|
||||
@@ -141,7 +140,7 @@ class _HighContrastToggleState extends State<_HighContrastToggle> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: widget.checked,
|
||||
label: widget.label,
|
||||
@@ -201,7 +200,7 @@ class _ThemeRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final bg = selected ? tokens.listItemSelectedBackground : (hovered ? tokens.listItemHoverBackground : tokens.listItemBackground);
|
||||
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
|
||||
return Semantics(
|
||||
|
||||
@@ -32,7 +32,7 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
// Listen to both the theme (label changes) and the overlay (open state).
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([_controller, _overlay]),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/appearance_control.dart';
|
||||
export 'src/extension.dart';
|
||||
export 'src/settings_view.dart';
|
||||
|
||||
@@ -66,8 +66,8 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
final d = c.detail;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a ticket to view details.', muted: true));
|
||||
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
final isDark = ClideTheme.of(ctx).dark;
|
||||
final tokens = ClideSettings.theme.of(ctx).surface;
|
||||
final isDark = ClideSettings.theme.of(ctx).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return ClidePaneChrome(
|
||||
@@ -148,16 +148,17 @@ class _TicketHeader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (detail.priority != null) ClideText(detail.priority!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (detail.priority != null)
|
||||
ClideText(detail.priority!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(detail.title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (detail.assignedTo != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -206,7 +207,7 @@ class _StatusControls extends StatelessWidget {
|
||||
border: Border.all(color: active ? tokens.statusInfo : tokens.panelBorder),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: ClideText(_shortLabel(s), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
child: ClideText(_shortLabel(s), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -234,7 +235,7 @@ class _SectionLabel extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideText(label, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily);
|
||||
return ClideText(label, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +271,7 @@ class _CompactCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText(title, fontSize: clideFontSmall)),
|
||||
],
|
||||
@@ -320,9 +321,10 @@ class _DecisionRefCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily),
|
||||
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (domain != null) ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (domain != null)
|
||||
ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
|
||||
@@ -168,7 +168,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
|
||||
@@ -197,7 +197,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
(byStatus[t.status ?? 'backlog'] ??= []).add(t);
|
||||
}
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
@@ -314,7 +314,12 @@ class _TypeChip extends StatelessWidget {
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
ClideText(
|
||||
label,
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: active ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -386,7 +391,7 @@ class _TicketCard extends StatelessWidget {
|
||||
entry.parentId!,
|
||||
fontSize: clideFontSmall,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -402,7 +407,13 @@ class _TicketCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600),
|
||||
ClideText(
|
||||
entry.id,
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalForeground,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -480,7 +491,7 @@ class _StatusBadge extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:clide/builtin/vim/src/vim_mode_service.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -17,10 +16,15 @@ class VimModeIndicator extends StatelessWidget {
|
||||
listenable: service,
|
||||
builder: (context, _) {
|
||||
if (!service.enabled) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ClideText('-- ${service.mode.label} --', fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.statusBarForeground),
|
||||
child: ClideText(
|
||||
'-- ${service.mode.label} --',
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ class WelcomeView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Tips card sits below the START/RECENT row when there's room
|
||||
@@ -105,18 +105,18 @@ class _TipsCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TIPS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('TIPS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 14),
|
||||
_tipRow(firstRow),
|
||||
_tipRow(firstRow, ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 8),
|
||||
_tipRow(secondRow),
|
||||
_tipRow(secondRow, ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tipRow(List<(String, String)> tips) {
|
||||
Widget _tipRow(List<(String, String)> tips, String mono) {
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < tips.length; i++) ...[
|
||||
@@ -126,7 +126,7 @@ class _TipsCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
),
|
||||
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily),
|
||||
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: mono),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -170,7 +170,7 @@ class _StartColumn extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 20),
|
||||
// Only flows that exist get a tile — the old Clone-from-git and
|
||||
// Start-a-Claude-session rows were inert and advertised shortcuts
|
||||
@@ -233,7 +233,7 @@ class _ActionRow extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground),
|
||||
),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -255,7 +255,7 @@ class _RecentColumn extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('RECENT', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('RECENT', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 20),
|
||||
if (recents.isEmpty)
|
||||
const ClideText('No recent projects.', muted: true, fontSize: clideFontCaption)
|
||||
@@ -309,7 +309,7 @@ class _RecentRow extends StatelessWidget {
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -323,7 +323,7 @@ class _RecentRow extends StatelessWidget {
|
||||
project.branch!,
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -399,17 +399,17 @@ class _StatusLine extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText('$clideName $clideVersion', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
ClideText('$clideName $clideVersion', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
||||
if (!tc.resolved)
|
||||
ClideText('checking…', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)
|
||||
ClideText('checking…', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context))
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
ClideText('application ok', fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(
|
||||
tc.missing.map((t) => '$t not found').join(' · '),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: tokens.statusWarning,
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
||||
@@ -435,8 +435,13 @@ class _ThemeLink extends StatelessWidget {
|
||||
builder: (ctx, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText('theme: ', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
ClideText('theme: ', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
ClideText(
|
||||
themeName,
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: hovered ? tokens.globalForeground : tokens.globalFocus,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -488,7 +493,7 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -518,7 +523,7 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: clideFontCaption,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
cursorColor: tokens.globalForeground,
|
||||
@@ -549,7 +554,7 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:clide/kernel/src/settings_schema.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// One atom contributed by a [ClideExtension]. Extensions declare N of
|
||||
@@ -138,3 +139,25 @@ class LayoutSlot {
|
||||
final double? maxSize;
|
||||
final bool visible;
|
||||
}
|
||||
|
||||
/// A category in the Settings panel (epic T-444). The kernel routes it into the
|
||||
/// `SettingsRegistry`; `builtin.settings-ui` renders its [SettingsCategory]
|
||||
/// schema into carded field rows (T-448). Categories are data — register one to
|
||||
/// surface a new settings tab.
|
||||
class SettingsCategoryContribution extends ContributionPoint {
|
||||
const SettingsCategoryContribution({required super.id, required this.category});
|
||||
|
||||
final SettingsCategory category;
|
||||
}
|
||||
|
||||
/// A bespoke widget for a `SettingsFieldKind.custom` field (T-452). The kernel
|
||||
/// routes it into the `SettingsControlRegistry` under [customId]; the settings
|
||||
/// renderer draws it when a field names that [customId]. Lets a subsystem ship
|
||||
/// a one-off control (e.g. the theme picker) without leaking widgets into the
|
||||
/// pure-data schema.
|
||||
class SettingsControlContribution extends ContributionPoint {
|
||||
const SettingsControlContribution({required super.id, required this.customId, required this.builder});
|
||||
|
||||
final String customId;
|
||||
final WidgetBuilder builder;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export 'src/log.dart';
|
||||
export 'src/file_log_sink.dart';
|
||||
export 'src/watchdog.dart';
|
||||
export 'src/settings.dart';
|
||||
export 'src/settings_schema.dart';
|
||||
export 'src/settings_registry.dart';
|
||||
export 'src/settings_control_registry.dart';
|
||||
export 'src/facade.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/commands/keybindings.dart';
|
||||
|
||||
@@ -25,6 +25,8 @@ import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/settings_registry.dart';
|
||||
import 'package:clide/kernel/src/settings_control_registry.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -55,6 +57,8 @@ class ExtensionManager extends ChangeNotifier {
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.ipc,
|
||||
required this.settingsRegistry,
|
||||
required this.settingsControlRegistry,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -81,6 +85,8 @@ class ExtensionManager extends ChangeNotifier {
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final DaemonClient ipc;
|
||||
final SettingsRegistry settingsRegistry;
|
||||
final SettingsControlRegistry settingsControlRegistry;
|
||||
|
||||
final Map<String, ClideExtension> _known = {};
|
||||
final Set<String> _activated = {};
|
||||
@@ -256,6 +262,11 @@ class ExtensionManager extends ChangeNotifier {
|
||||
// Presets are consumed by the default-layout extension in its
|
||||
// own activate(); nothing for the kernel to do here.
|
||||
break;
|
||||
case SettingsCategoryContribution s:
|
||||
// register() throws on a duplicate id, rolling activation back.
|
||||
settingsRegistry.register(s.category);
|
||||
case SettingsControlContribution s:
|
||||
settingsControlRegistry.register(s.customId, s.builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +287,10 @@ class ExtensionManager extends ChangeNotifier {
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
break;
|
||||
case SettingsCategoryContribution s:
|
||||
settingsRegistry.unregister(s.category.id);
|
||||
case SettingsControlContribution s:
|
||||
settingsControlRegistry.unregister(s.customId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import 'package:clide/kernel/src/recent_files.dart';
|
||||
import 'package:clide/kernel/src/scheduler.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/settings_registry.dart';
|
||||
import 'package:clide/kernel/src/settings_control_registry.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/loader.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
@@ -81,10 +83,18 @@ class KernelServices {
|
||||
required this.textZoom,
|
||||
required this.toast,
|
||||
required this.logRing,
|
||||
required this.settingsRegistry,
|
||||
required this.settingsControlRegistry,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final SettingsStore settings;
|
||||
|
||||
/// Categories registered for the Settings panel (T-444).
|
||||
final SettingsRegistry settingsRegistry;
|
||||
|
||||
/// Bespoke widgets for custom settings fields (T-452).
|
||||
final SettingsControlRegistry settingsControlRegistry;
|
||||
final DaemonBus events;
|
||||
final MessageBus messages;
|
||||
|
||||
@@ -155,6 +165,8 @@ class KernelServices {
|
||||
|
||||
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
|
||||
await settings.load();
|
||||
final settingsRegistry = SettingsRegistry();
|
||||
final settingsControlRegistry = SettingsControlRegistry();
|
||||
|
||||
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
|
||||
for (final ns in preloadNamespaces) {
|
||||
@@ -238,6 +250,8 @@ class KernelServices {
|
||||
focus: focus,
|
||||
project: project,
|
||||
ipc: ipc,
|
||||
settingsRegistry: settingsRegistry,
|
||||
settingsControlRegistry: settingsControlRegistry,
|
||||
);
|
||||
|
||||
if (autoStartDaemonClient) {
|
||||
@@ -248,6 +262,8 @@ class KernelServices {
|
||||
log: log,
|
||||
logRing: logRing,
|
||||
settings: settings,
|
||||
settingsRegistry: settingsRegistry,
|
||||
settingsControlRegistry: settingsControlRegistry,
|
||||
events: events,
|
||||
messages: messages,
|
||||
filterStates: filterStates,
|
||||
@@ -287,6 +303,7 @@ class KernelServices {
|
||||
await ipc.stop();
|
||||
ipc.dispose();
|
||||
settings.dispose();
|
||||
settingsRegistry.dispose();
|
||||
theme.dispose();
|
||||
panels.dispose();
|
||||
arrangement.dispose();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"notice.title": { "translation": "Extension management is coming" },
|
||||
"notice.body": { "translation": "Installing, enabling, and disabling extensions arrives with third-party (Lua) extension support. For now the built-in extensions are always on." },
|
||||
"notice.tracked": { "translation": "Tracked in T-8 (Tier 6) · D-16" }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"modal.title": { "translation": "Settings" },
|
||||
"modal.close": { "translation": "Close" },
|
||||
"modal.close.hint": { "translation": "Close settings without changing anything" },
|
||||
"rail.header": { "translation": "Categories" },
|
||||
"panel.empty": { "translation": "No settings categories are registered yet." },
|
||||
"search.hint": { "translation": "Search settings…" },
|
||||
"search.empty": { "translation": "No settings match your search." },
|
||||
"scope.project": { "translation": "This project" },
|
||||
"scope.always": { "translation": "All clide" },
|
||||
"scope.default": { "translation": "Default" },
|
||||
"scope.reset": { "translation": "Reset to default" },
|
||||
"scope.tip.project": { "translation": "Stored in this project (.clide)" },
|
||||
"scope.tip.always": { "translation": "Stored for all clide (~/.clide)" },
|
||||
"scope.tip.default": { "translation": "Unset — using the default" }
|
||||
}
|
||||
@@ -97,6 +97,85 @@ class SettingsStore extends ChangeNotifier {
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
// --- Scope-explicit access (per-field scope tags, T-449) ---------------
|
||||
//
|
||||
// [get]/[set] resolve a key by its prefix; the settings panel's scope tag
|
||||
// needs to read, write, and clear a key at a *specific* storage layer. There
|
||||
// are two storage files: app (`~/.clide`, "Always") and project (`.clide`,
|
||||
// "Project"). `ext.*` keys may live in either (project overrides app);
|
||||
// `app.*`/`project.*` keys live only in their prefix's layer.
|
||||
|
||||
/// Raw value stored in a specific storage layer (no cross-layer fallback).
|
||||
/// [SettingsScope.ext] is a key class, not a layer, so it returns null.
|
||||
Object? rawAt(SettingsScope layer, String key) => switch (layer) {
|
||||
SettingsScope.app => _appValues[key],
|
||||
SettingsScope.project => _projectValues[key],
|
||||
SettingsScope.ext => null,
|
||||
};
|
||||
|
||||
/// The storage layer currently supplying [key]'s value (project overrides app
|
||||
/// for `ext.*`), or null when unset (Default). Honors the key's prefix.
|
||||
SettingsScope? effectiveLayer(String key) {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
return _appValues.containsKey(key) ? SettingsScope.app : null;
|
||||
case SettingsScope.project:
|
||||
return _projectValues.containsKey(key) ? SettingsScope.project : null;
|
||||
case SettingsScope.ext:
|
||||
if (_projectValues.containsKey(key)) return SettingsScope.project;
|
||||
if (_appValues.containsKey(key)) return SettingsScope.app;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The storage layers [key] may be written to, by prefix: `app.*` → [app];
|
||||
/// `project.*` → [project]; `ext.*` → [project, app].
|
||||
List<SettingsScope> writableLayers(String key) {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
return const [SettingsScope.app];
|
||||
case SettingsScope.project:
|
||||
return const [SettingsScope.project];
|
||||
case SettingsScope.ext:
|
||||
return const [SettingsScope.project, SettingsScope.app];
|
||||
}
|
||||
}
|
||||
|
||||
/// Write [key] = [value] into a specific storage layer. Throws if the project
|
||||
/// layer is requested with no project open, or if [SettingsScope.ext] (not a
|
||||
/// layer) is passed.
|
||||
Future<void> setAt(SettingsScope layer, String key, Object? value) async {
|
||||
switch (layer) {
|
||||
case SettingsScope.app:
|
||||
_appValues[key] = value;
|
||||
await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir == null) {
|
||||
throw StateError('Cannot set project-scoped key with no project open: $key');
|
||||
}
|
||||
_projectValues[key] = value;
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
case SettingsScope.ext:
|
||||
throw ArgumentError('ext is a key class, not a storage layer');
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Remove [key] from a specific storage layer (no-op if absent).
|
||||
Future<void> removeAt(SettingsScope layer, String key) async {
|
||||
switch (layer) {
|
||||
case SettingsScope.app:
|
||||
if (_appValues.remove(key) != null) await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir != null && _projectValues.remove(key) != null) {
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
}
|
||||
case SettingsScope.ext:
|
||||
throw ArgumentError('ext is a key class, not a storage layer');
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readFile(File f) async {
|
||||
String txt;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Holds the bespoke widgets that draw [SettingsFieldKind.custom] fields
|
||||
/// (T-452). A subsystem registers a builder under a `customId` (via
|
||||
/// `SettingsControlContribution`, routed by the extension manager); the
|
||||
/// settings renderer looks it up when it meets a custom field.
|
||||
///
|
||||
/// Controls register at activation, before any settings modal opens, so this
|
||||
/// is a plain registry — no change notification needed.
|
||||
class SettingsControlRegistry {
|
||||
final Map<String, WidgetBuilder> _byId = <String, WidgetBuilder>{};
|
||||
|
||||
/// Register the [builder] for [customId]. Throws on a duplicate id so a
|
||||
/// collision rolls the contributing extension's activation back.
|
||||
void register(String customId, WidgetBuilder builder) {
|
||||
if (_byId.containsKey(customId)) {
|
||||
throw StateError('duplicate settings control id: $customId');
|
||||
}
|
||||
_byId[customId] = builder;
|
||||
}
|
||||
|
||||
void unregister(String customId) => _byId.remove(customId);
|
||||
|
||||
/// The builder for [customId], or null when none is registered.
|
||||
WidgetBuilder? builderFor(String customId) => _byId[customId];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:clide/kernel/src/settings_schema.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Holds the [SettingsCategory] schemas subsystems register against the kernel
|
||||
/// (via `SettingsCategoryContribution`, routed by the extension manager). The
|
||||
/// settings panel reads this to build its rail + panels (T-447/T-448) and
|
||||
/// rebuilds when the set changes.
|
||||
class SettingsRegistry extends ChangeNotifier {
|
||||
final Map<String, SettingsCategory> _byId = <String, SettingsCategory>{};
|
||||
|
||||
/// Registered categories, sorted by (priority, then case-insensitive title).
|
||||
List<SettingsCategory> get categories {
|
||||
final list = _byId.values.toList()
|
||||
..sort((a, b) {
|
||||
final p = a.priority.compareTo(b.priority);
|
||||
return p != 0 ? p : a.title.toLowerCase().compareTo(b.title.toLowerCase());
|
||||
});
|
||||
return List.unmodifiable(list);
|
||||
}
|
||||
|
||||
SettingsCategory? byId(String id) => _byId[id];
|
||||
|
||||
/// Register a category. Throws on a duplicate id — a collision is a wiring
|
||||
/// bug that should roll the contributing extension's activation back, the
|
||||
/// same way duplicate command/slot ids do.
|
||||
void register(SettingsCategory category) {
|
||||
if (_byId.containsKey(category.id)) {
|
||||
throw StateError('duplicate settings category id: ${category.id}');
|
||||
}
|
||||
_byId[category.id] = category;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void unregister(String id) {
|
||||
if (_byId.remove(id) != null) notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/// Schema model for the settings panel (T-448, epic T-444).
|
||||
///
|
||||
/// Pure data — no Flutter imports — so any subsystem can declare a category
|
||||
/// without depending on the widget layer. The settings-ui renderer turns a
|
||||
/// [SettingsCategory] into carded sections of field rows; each field binds to
|
||||
/// a `SettingsStore` key and is read/written through the store.
|
||||
library;
|
||||
|
||||
/// The control a [SettingsField] renders as.
|
||||
enum SettingsFieldKind {
|
||||
/// On/off boolean.
|
||||
toggle,
|
||||
|
||||
/// One value chosen from [SettingsField.options].
|
||||
select,
|
||||
|
||||
/// Free-text input.
|
||||
text,
|
||||
|
||||
/// Numeric input (optionally bounded by [SettingsField.min]/[max]).
|
||||
number,
|
||||
|
||||
/// A row that opens an external file/editor (e.g. `.editorconfig`) instead
|
||||
/// of editing a value inline — the action is a command id, keeping the
|
||||
/// schema widget-free.
|
||||
file,
|
||||
|
||||
/// A bespoke control rendered by a widget the owning subsystem registers
|
||||
/// (via `SettingsControlContribution`) under [SettingsField.customId]. Keeps
|
||||
/// the schema widget-free while allowing the one-off controls the generic
|
||||
/// kinds can't express (e.g. the Appearance theme picker).
|
||||
custom,
|
||||
}
|
||||
|
||||
/// One choice in a [SettingsFieldKind.select] field.
|
||||
class SettingsOption {
|
||||
const SettingsOption({required this.value, required this.label});
|
||||
|
||||
/// Stored value.
|
||||
final String value;
|
||||
|
||||
/// Human label shown in the picker.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// One editable setting. [key] is a `SettingsStore` key — its `app.`/
|
||||
/// `project.`/`ext.` prefix determines the scope (and the per-field scope tag,
|
||||
/// T-449). The renderer reads the current value with `store.get`, falling back
|
||||
/// to [defaultValue] when unset, and writes edits with `store.set`.
|
||||
class SettingsField {
|
||||
const SettingsField({
|
||||
required this.key,
|
||||
required this.kind,
|
||||
required this.label,
|
||||
this.help,
|
||||
this.defaultValue,
|
||||
this.options = const [],
|
||||
this.min,
|
||||
this.max,
|
||||
this.fileCommand,
|
||||
this.applyCommandPrefix,
|
||||
this.customId,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final SettingsFieldKind kind;
|
||||
final String label;
|
||||
|
||||
/// Optional one-line help shown under the label.
|
||||
final String? help;
|
||||
|
||||
/// Value shown / restored when the key is unset (reset-to-default target).
|
||||
final Object? defaultValue;
|
||||
|
||||
/// Choices for [SettingsFieldKind.select].
|
||||
final List<SettingsOption> options;
|
||||
|
||||
/// Optional inclusive bounds for [SettingsFieldKind.number].
|
||||
final num? min;
|
||||
final num? max;
|
||||
|
||||
/// For [SettingsFieldKind.file]: the command id the row's button invokes.
|
||||
final String? fileCommand;
|
||||
|
||||
/// For [SettingsFieldKind.select]: when set, picking option `<value>` runs
|
||||
/// the command `<applyCommandPrefix><value>` instead of writing [key]
|
||||
/// directly — for settings a subsystem applies via a command (and only then
|
||||
/// persists). The current value is still read from [key], so the scope tag
|
||||
/// and selection still work. Example: `'keymap.preset.'` → `keymap.preset.vim`.
|
||||
final String? applyCommandPrefix;
|
||||
|
||||
/// For [SettingsFieldKind.custom]: the id the renderer looks up in the
|
||||
/// `SettingsControlRegistry` to find the widget that draws this field.
|
||||
final String? customId;
|
||||
}
|
||||
|
||||
/// A carded group of fields (surface.md "sectioned cards"). [label] is the
|
||||
/// small-caps header rendered just above the card.
|
||||
class SettingsSection {
|
||||
const SettingsSection({required this.label, required this.fields});
|
||||
|
||||
final String label;
|
||||
final List<SettingsField> fields;
|
||||
}
|
||||
|
||||
/// One settings category — a rail entry (T-447) plus the sections its panel
|
||||
/// shows. Subsystems register these via `SettingsCategoryContribution`; the
|
||||
/// renderer draws them.
|
||||
class SettingsCategory {
|
||||
const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
/// Phosphor glyph name, resolved via `PhosphorIcons.byName` at render (T-314).
|
||||
final String? iconName;
|
||||
|
||||
/// Rail ordering — lower sorts first; ties broken by [title].
|
||||
final int priority;
|
||||
|
||||
final List<SettingsSection> sections;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class HatBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => kernel.window.startDrag(),
|
||||
child: Container(
|
||||
|
||||
@@ -65,7 +65,7 @@ class RootLayout extends StatelessWidget {
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
border: Border(top: BorderSide(color: ClideSettings.theme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
@@ -74,7 +74,7 @@ class RootLayout extends StatelessWidget {
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
border: Border(top: BorderSide(color: ClideSettings.theme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -136,7 +136,7 @@ class _BottomRail extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
@@ -202,7 +202,7 @@ class StatusbarCollapseToggle extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (!visible) return const SizedBox(width: 24);
|
||||
// The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane
|
||||
// toward its own edge, expanding brings it back toward the centre.
|
||||
@@ -231,7 +231,7 @@ class StatusbarHost extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
|
||||
@@ -34,7 +34,12 @@ class ProjectSwitcherButton extends StatelessWidget {
|
||||
builder: (context, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
|
||||
ClideText(
|
||||
label,
|
||||
fontSize: 12,
|
||||
color: hovered ? tokens.globalForeground : tokens.chromeForeground,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
|
||||
],
|
||||
@@ -97,7 +102,7 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final recents = widget.kernel.project.recents;
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
|
||||
@@ -195,7 +200,7 @@ class _RecentProjectRow extends StatelessWidget {
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -203,11 +208,18 @@ class _RecentProjectRow extends StatelessWidget {
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
)
|
||||
else
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -236,7 +248,8 @@ class _ActionRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (shortcut != null && shortcut!.isNotEmpty)
|
||||
ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+124
-110
@@ -48,6 +48,8 @@ class RootShellState extends State<RootShell> {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
// Re-apply the UI font when its setting changes (T-460).
|
||||
widget.services.settings.addListener(_onZoom);
|
||||
_globalSeq = SequenceMatcher(
|
||||
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
|
||||
context: () => widget.services.keymap.scope,
|
||||
@@ -61,6 +63,7 @@ class RootShellState extends State<RootShell> {
|
||||
HardwareKeyboard.instance.removeHandler(_onRawKey);
|
||||
_seqTimeout?.cancel();
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
widget.services.settings.removeListener(_onZoom);
|
||||
_menuBar.dispose();
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
@@ -70,117 +73,128 @@ class RootShellState extends State<RootShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.quickOpen.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.exLine.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
|
||||
onInvoke: (_) {
|
||||
// ZZ — save+close the active tab without opening the overlay.
|
||||
unawaited(exWriteQuitActive(widget.services.ipc));
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
HatBar(kernel: widget.services, menuBar: _menuBar),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const ExLineOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
// Resolve the user-selected fonts once (Settings → Appearance, T-460/T-471)
|
||||
// and publish them via ClideFonts; the settings listener rebuilds this on a
|
||||
// change so descendants re-read live. The UI font flows through the
|
||||
// DefaultTextStyle below; mono sites read ClideFonts.monoOf(context).
|
||||
final settings = widget.services.settings;
|
||||
final uiFont = settings.get<String>(kUiFontSettingKey) ?? clideUiFamily;
|
||||
final monoFont = settings.get<String>(kMonoFontSettingKey) ?? clideMonoFamily;
|
||||
return ClideSettingsScope(
|
||||
ui: uiFont,
|
||||
mono: monoFont,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: uiFont,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.quickOpen.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.exLine.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
|
||||
onInvoke: (_) {
|
||||
// ZZ — save+close the active tab without opening the overlay.
|
||||
unawaited(exWriteQuitActive(widget.services.ipc));
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
HatBar(kernel: widget.services, menuBar: _menuBar),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const ExLineOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -312,7 +326,7 @@ class _WelcomeOverlay extends StatelessWidget {
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
final tokens = ClideSettings.theme.of(ctx).surface;
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ class _SlotHostState extends State<SlotHost> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return FocusScope(
|
||||
node: _scope,
|
||||
onFocusChange: _onFocusChange,
|
||||
@@ -84,7 +84,7 @@ class _SlotBody extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
@@ -125,7 +125,7 @@ class _SidebarSlot extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
@@ -156,7 +156,7 @@ class _WorkspaceSlot extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
@@ -218,7 +218,7 @@ class _RevealedTab extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -277,7 +277,7 @@ class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
final ratio = widget.arrangement.editorRatio;
|
||||
@@ -356,7 +356,7 @@ class _ContextSlot extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
@@ -368,5 +368,5 @@ String resolveTabTitle(BuildContext context, TabContribution t) {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
return ClideSettings.i18n.of(context).string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor.dart';
|
||||
@@ -26,7 +26,7 @@ class ClideAccordion extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -43,7 +43,7 @@ class ClideAccordion extends StatelessWidget {
|
||||
'$label · $count',
|
||||
fontSize: clideFontSmall,
|
||||
color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/clide_settings.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -27,7 +27,7 @@ class ClideButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final enabled = onPressed != null;
|
||||
|
||||
return Semantics(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'clide_settings.dart';
|
||||
|
||||
class ClideCodeBlock extends StatefulWidget {
|
||||
const ClideCodeBlock({super.key, required this.source, this.language});
|
||||
|
||||
@@ -49,8 +50,13 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final style = TextStyle(fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback, fontSize: clideFontMono, color: tokens.globalForeground);
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final style = TextStyle(
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalForeground,
|
||||
);
|
||||
|
||||
final spans = _spans;
|
||||
TextSpan textSpan;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user