53 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.8 2fe5d6d3e8 release v2.7.1
Patch on 2.7.0: Josefin Sans is the default UI font again (Inter stays
selectable), and Claude's markdown-rendered prose + links now honour the
live UI-font setting instead of pinning the bundled default (T-475).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:37:59 +02:00
jpmschweitzerandClaude Opus 4.8 8130d6b43a feat(fonts): default UI font back to Josefin Sans
Reverts the Inter default introduced in 2.7.0 — Josefin Sans (Light, w300) is
the default UI face again. Inter stays bundled and selectable in Settings →
Appearance; JetBrains Mono remains the default monospace with Fira Mono
selectable. Flips clideUiFamily/default weight, the picker's default + option
order, and the licenses.yaml purpose notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:36:30 +02:00
jpmschweitzerandClaude Opus 4.8 90546cb446 fix(fonts): markdown prose + links honour the live UI font (T-475)
The markdown span builders are context-free statics, so they pinned the
bundled clideUiFamily const and ignored the Appearance UI-font setting —
Claude's conversation prose stayed on the default face regardless of the
pick. Thread the resolved UI family through the same ClideMarkdownHooks
carrier already used for mono (T-472): build() resolves it from context and
every prose/link span reads hooks.ui. Adds a regression test asserting prose
and inline code follow the families from the ClideSettingsScope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:35:51 +02:00
jpmschweitzerandClaude Opus 4.8 258090926d release v2.7.0
Settings panel epic: schema-driven settings engine (modal shell, category
rail, per-field scope tags, cross-category search) with Activity, Keymap,
Appearance, Extensions, and Claude categories; Inter as the default UI font
plus live UI + monospace font pickers (Inter, Fira Mono bundled). Backed by
the ClideSettings facade (D-101) unifying theme, i18n, and fonts behind one
widget-facing read entry, with consumer reads migrated onto it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:37:00 +02:00
jpmschweitzerandClaude Opus 4.8 160cc81741 fix(test): de-flake theme_persistence project-write assertion under load
The "persists project.theme into that repo" case asserted the .clide
settings file existed after a single pumpEventQueue, but the write is
fire-and-forget real I/O — one event-queue drain doesn't guarantee the disk
flush, so it flaked in the loaded coverage pool. Poll for the file (bounded)
instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:30:47 +02:00
jpmschweitzerandClaude Opus 4.8 18c8e702fb test(settings): cover the schema-engine edit/file/scope/search paths
Exercise the previously-untested branches of the settings category renderer:
number-field commit/clamp/unparseable-revert, text-field commit, the file
control's command button, the select raw-value fallback, scope move-to-app,
field help text, and the cross-category search empty state. Restores the
coverage floor after the D-101 facade migration shifted the ratio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:27:59 +02:00
jpmschweitzerandClaude Opus 4.8 a5d6559d24 fix(test): pty_log_test imports package:test, not flutter_test
The breadcrumb unit test (T-434) pulled package:flutter_test, which imports
dart:ui — unavailable under plain `dart test`. That broke the flutter-free
core batch (ci/test_core.sh) for every file compiled after it. It uses only
package:test APIs, so the import was simply wrong; siblings already use
package:test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:27:12 +02:00
jpmschweitzerandClaude Opus 4.8 18cd945380 refactor(theme): route consumer reads through ClideSettings.theme (T-473)
Migrate ClideTheme.of(context) reads across the widget, feature, and shell
layers to the unified ClideSettings.theme.of(context) facade (D-101), so
theme/i18n/fonts/settings share one widget-facing entry. The facade delegates
straight to ClideTheme, so behaviour is unchanged — goldens are unmoved.

The low-level theme provider keeps its direct ClideTheme.of: the facade is
built on it, and the two kernel sites (ClideTheme's own definition + the
panels drag-resize widget) stay direct to avoid a widgets→kernel import cycle.
Dead controller.dart/kernel.dart imports left by the sweep removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:47:15 +02:00
jpmschweitzerandClaude Opus 4.8 1c79c9c4dc refactor(i18n): route consumer reads through ClideSettings.i18n (T-474)
Migrate widget i18n reads from ClideKernel.of(context).i18n to the unified
ClideSettings.i18n.of(context) facade (D-101), so settings/theme/fonts/i18n
share one widget-facing entry point. The facade delegates to the same I18n
service, so behaviour is unchanged. Infra plumbing (KernelServices /
ExtensionManager fields, the slot-host Listenable.merge) keeps its direct
kernel reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:37:51 +02:00
jpmschweitzerandClaude Opus 4.8 c4248e3228 fix(fonts): honour the monospace setting in context-free render helpers (T-472)
Eleven leaf sites rendered monospace text from top-level/static helpers that
take no BuildContext, so they hard-coded clideMonoFamily and ignored the
Settings → Appearance Monospace choice that the D-101 facade made live
everywhere else.

Thread the resolved family in from the nearest context-bearing caller:
- claude tool bodies/results: a required `mono` field on _ConversationTurn +
  a `mono` arg on the shared toolInputBody chain;
- markdown inline `code`/record/file-ref spans: carried on ClideMarkdownHooks,
  which build() already constructs from context and threads to every static;
- search preview styles and welcome tips: a `mono` parameter on the helpers.

No behaviour change when the setting is default; these surfaces now switch
live with the rest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:35:38 +02:00
jpmschweitzerandClaude Opus 4.8 6b5317342b chore(plan): file T-472/T-473/T-474 (ClideSettings migration follow-ups)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:00:28 +02:00
jpmschweitzerandClaude Opus 4.8 b8e6629df5 feat(settings): monospace font picker + bundle Fira Mono (T-471)
Settings → Appearance gains a Monospace font select (JetBrains Mono / Fira
Mono) on the schema engine, writing app.mono.font. It applies live: root_shell
resolves the family into the ClideSettingsScope (D-101) and rebuilds on the
settings change, so the ~93 migrated mono sites — terminal, diffs, code, IDs —
re-read the new family at once.

Vendor Fira Mono (Regular + Bold, OFL) under assets/fonts/fira_mono/ with the
pubspec font family + licenses.yaml entry per D-42; JetBrains Mono stays the
default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:59:47 +02:00
jpmschweitzerandClaude Opus 4.8 a7ea38acde refactor(settings): ClideSettings — one live-preferences facade (D-101)
Introduce ClideSettings, a single widget-facing facade for the app's live
user preferences, namespaced by concern: ClideSettings.fonts.monoOf(context)
/ .fonts.uiOf(context), ClideSettings.theme.of(context),
ClideSettings.i18n.of(context). "Plumb once, use many."

Fonts are carried by a new root-provided ClideSettingsScope (resolved from the
font settings in root_shell, rebuilt on change); theme and i18n delegate to
their existing live providers (ClideTheme / the I18n service) so there's one
source of truth and their many consumers migrate incrementally rather than in
a big-bang. Reads outside a scope fall back to the bundled font defaults, so a
widget renders without a provider (isolated tests).

Migrate ~93 monospace-font call sites across 33 files from the clideMonoFamily
const to ClideSettings.fonts.monoOf(context) — pure refactor, identical family
when no override is set. 11 context-less helper sites (markdown static spans,
a few top-level/static builders) keep the const for now and are tracked in
T-472. Records D-101; updates the ui-design skill's font-family rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:57:16 +02:00
jpmschweitzerandClaude Opus 4.8 8ad5c9ba6e chore(plan): file T-471 (monospace font picker follow-up)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:16:38 +02:00
jpmschweitzerandClaude Opus 4.8 21d435d1f1 feat(settings): Inter as default UI font + UI font picker (T-460)
Vendor Inter (variable + italic, OFL) under assets/fonts/inter/ and make it
the default application UI face, replacing Josefin Sans (which stays bundled
as a selectable option). pubspec font family + licenses.yaml entry per D-42.

Settings → Appearance gains a UI-font select (Inter / Josefin Sans). The root
DefaultTextStyle reads app.ui.font (kUiFontSettingKey) over the default and
re-applies live on settings change, so a pick takes effect immediately. Bump
the default UI weight to w400 — Inter reads better at Regular than Josefin's
Light.

The monospace picker is deferred to T-471: clideMonoFamily is a const at ~38
call sites (not inherited), so a live mono picker needs the family routed
through context first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:15:01 +02:00
jpmschweitzerandClaude Opus 4.8 b1057b41c8 chore(plan): file T-470 (Claude apply-to-current prompt follow-up)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:00:46 +02:00
jpmschweitzerandClaude Opus 4.8 ea670fd077 feat(settings): Claude category — new-session defaults (T-457)
A Settings → Claude category sets per-user defaults for NEW sessions: model,
effort, and permission mode (generic schema selects persisting
app.claude.default*). The pane reads them at spawn — effort flows through the
existing --effort flag (SpawnSpec.effort); model and permission mode are sent
as control requests right after a fresh (non-resume, non-fork) session starts.
'default'/unset values are no-ops, leaving the CLI's own defaults.

The optional "settings changed — apply to current sessions?" prompt is filed
as T-470: it needs a custom control (a generic select can't prompt), so it's
a clean follow-up on the T-452 escape hatch.

Tests: defaultEffortFlag sentinel handling; the category contributes the three
default fields. The thin pane-side applySessionDefaults is covered by the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:58:11 +02:00
jpmschweitzerandClaude Opus 4.8 d9eadf295a chore(plan): sync settings-epic ticket statuses
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:11:15 +02:00
jpmschweitzerandClaude Opus 4.8 de1e96c510 style(test): dart format settings + keymap category tests
Reflow lines `dart format` collapses — the T-451/T-452 test additions were
committed before `make format` ran, so the committed copies were unformatted.
No behavior change; keeps the push format gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:07:47 +02:00
jpmschweitzerandClaude Opus 4.8 79c5294d44 feat(settings): Extensions tab — "watch this space" notice (T-456)
Built-in extensions are always on and there's no third-party install path yet,
so the Extensions tab is a notice rather than a toggle list — a toggle list
would let users brick the app by disabling load-bearing builtins. It explains
that installing/enabling/disabling arrives with third-party (Lua) extensions
and points at the records that pick it up (D-16 / T-8).

Fleshes out the builtin.extensions-ui stub via the custom-control hatch. Adds
two renderer guards — empty section/field labels render no chrome — so a
notice-only category shows just its card.

Tests: contributes the Extensions category + notice control; the notice renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:45:01 +02:00
jpmschweitzerandClaude Opus 4.8 757d6f71fa feat(settings): Appearance category + custom-control escape hatch (T-452)
Add the one bespoke control the schema engine defers to. New
SettingsControlContribution routes a WidgetBuilder into a kernel
SettingsControlRegistry under a customId; a SettingsFieldKind.custom field
names that id, and the renderer draws the registered widget full-width
(label on top, no scope tag — the control owns its own apply + scope).

The theme-picker extension uses it: an Appearance category whose theme field
is custom, backed by AppearanceThemeControl — base-theme chips + a
high-contrast toggle that apply live through ThemeController (persisted by
theme_persistence). Reuses the shared theme_families helpers.

Tests: control registry (register/dup/unregister), the renderer's custom-field
path, and the Appearance contribution + live theme apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:04:52 +02:00
jpmschweitzerandClaude Opus 4.8 727fe8fdd4 feat(settings): Keymap category — preset select (T-451)
The keybindings-ui extension contributes a Keymap SettingsCategory: a preset
select (Default / Vim / VS Code / JetBrains) reading the active preset from
kKeymapPresetSetting. Picking one routes through a new schema affordance —
SettingsField.applyCommandPrefix — running `keymap.preset.<value>`, which calls
KeymapService.setPreset to persist and reload the layered keymap live. The
prefix path keeps the engine generic: settings a subsystem applies via a
command (rather than a bare key write) declare the prefix; the scope tag and
current-value read still use the key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:51:14 +02:00
jpmschweitzerandClaude Opus 4.8 7aab93e776 chore(config): deny bulk git add (-A / --all / .) in project settings
Deny Bash(git add -A*), Bash(git add --all*), and Bash(git add .) so the
bulk-stage forms can't run; deny overrides the existing git add * allow. The
git-commit skill mandates specific-path staging, and a shared working tree
makes -A actively dangerous (it sweeps another agent's files into the commit).
Exact-match the bare dot rather than git add .* so git add .gitignore and
other dotfile paths still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:44:05 +02:00
jpmschweitzerandClaude Opus 4.8 291f997d31 feat(settings): Activity category — conversation fold level (T-453)
The first real settings tab. The Claude extension contributes an Activity
SettingsCategory whose fold-level select binds to app.claude.activityFoldLevel;
the activity panes already rebuild off the settings notifier, so a pick applies
live. Proves the schema-driven engine end to end with a production setting —
opening Settings now shows a populated, working category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:41:20 +02:00
jpmschweitzerandClaude Opus 4.8 76d8dadc25 docs: forbid git add -A in CLAUDE.md, require git-commit skill
A concurrent session's `git add -A` swept this turn's uncommitted doc
edits into unrelated feat(settings) commits (f56ad88, f643d6e). Add an
explicit-staging guardrail to the Git workflow section and route all
commits through the git-commit skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:33:41 +02:00
jpmschweitzerandClaude Opus 4.8 f643d6e747 feat(settings): cross-category settings search (T-450)
A search box (ClideFilterBox) atop the rail filters fields across every
registered category. While searching, the panel swaps to a results view that
groups the matching fields under category subheaders — rendered with the same
carded rows and editable inline — and each rail row shows its match count with
zero-match categories dimmed. Completes the settings-UI infra spine (T-444):
shell, engine, rail, scope tags, search.

Tests: search filters across categories, hides non-matches, and surfaces the
per-category rail count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:24:24 +02:00
jpmschweitzerandClaude Opus 4.8 f56ad88439 feat(settings): per-field scope tags + scope resolution (T-449)
Each settings field gains a scope tag showing where its value lives — folder
= Project (.clide), globe = Always (~/.clide), circle-dashed = Default/unset —
colour-coded (statusSuccess / statusWarning / muted) with a tooltip. Tapping
opens a menu to move the value between the scopes the key supports, or reset
to default; the tag's menu replaces the interim reset button.

Backs it with scope-explicit SettingsStore access — rawAt / setAt / removeAt /
effectiveLayer / writableLayers — over the two storage files (app ~/.clide,
project .clide). ext.* keys layer project-over-app; app.*/project.* keys live
only in their prefix's file, so their menu offers that one scope + reset.

Tests: store scope ops (layering, reload, guards) and the tag (Default vs
All-clide rendering, menu reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:18:38 +02:00
jpmschweitzerandClaude Opus 4.8 6c6b0c731e feat(settings): category rail + navigation (T-447)
The settings modal's left rail now lists the registered categories (icon +
title, data-driven from the SettingsRegistry) with an accent left-stripe +
surfaceHi selection per ui-design surface.md. Selecting a category drives the
modal's selection state and swaps the right panel; the rail scrolls when the
set exceeds the height. The cross-category search box atop the rail lands in
T-450.

Tests: rail lists categories and selecting one swaps the panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:09:56 +02:00
jpmschweitzerandClaude Opus 4.8 4bbb0ee4b3 feat(settings): schema-driven settings engine (T-448)
The core of the settings panel (epic T-444). Subsystems describe a category
as data — a SettingsCategory of carded SettingsSections of SettingsFields
(toggle / select / text / number / opens-external-file), each bound to a
SettingsStore key with help text, a default, and reset-to-default.

Registration is declarative: a new SettingsCategoryContribution carries the
category; the extension manager routes it into a new kernel SettingsRegistry
(exposed on KernelServices), which the panel reads via ClideKernel. Adding a
category is now pure data + a contribution — no widget code.

SettingsCategoryView renders a category into carded sections per ui-design
surface.md: panelHeader card fill, dividerColor border, inputs receding to
panelBackground; select reuses the anchored-overlay menu, text/number commit
on Enter or blur (numeric clamps to bounds). The modal panel now shows the
selected/first registered category, falling back to the empty state.

Tests: registry (sort / dedup / notify), contribution routing on activation,
renderer (render + toggle/select write-through + reset), modal-with-category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:07:51 +02:00
jpmschweitzerandClaude Opus 4.8 643f40d7b2 feat(settings): settings panel shell + settings.open command (T-445)
Lay the foundation for the schema-driven settings UI (epic T-444). A new
`settings.open` command (⌘`,`, plus a File-menu and command-palette entry)
opens a centered Settings modal over the dimmed app via the dialog router,
built from the modalSurface* tokens (D-7, no Material). The shell frames the
two regions later tickets fill in — the category rail (T-447) and the
scrolling carded panel (T-448) — and dismisses on ✕, Esc, or barrier tap.
With no category registered yet it shows its empty state, which is the
correct runtime state.

Flesh out the `builtin.settings-ui` stub (was 0.0.0-stub) into a real
extension; ship its en-US i18n catalog. Relabel the theme picker's
`theme.pick` command title from "Settings…" to "Theme…" so the two no
longer collide in the palette (the picker folds into the new panel's
Appearance category in T-452).

Tests: command + ⌘`,` binding registered, shell renders, Esc and close
both dismiss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:41:20 +02:00
jpmschweitzerandClaude Opus 4.8 50d10c792a chore(plan): file T-461 (red error border on API-error cards)
Flag API-error conversation cards with a red statusError border (the card
already has an error status + bordered variant; just needs wiring). UI
tracker T-276.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:42:54 +02:00
jpmschweitzerandClaude Opus 4.8 f5fab2f4c2 chore(plan): file T-460 (Appearance font pickers + bundle Inter)
UI + monospace font pickers in the Appearance settings tab; bundle Inter
(SIL OFL) as the default UI font. Child of the settings-UI epic T-444,
renders alongside the theme picker (T-452).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:40:32 +02:00
jpmschweitzerandClaude Opus 4.8 c661f76c3b sync licenses.yaml self.version to 2.6.0
self.version is auto-synced from pubspec by make gen-build-info; pubspec
is already at 2.6.0 but the generated value had lagged at 2.5.0. Commit
the regenerated file so the working tree matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:17 +02:00
jpmschweitzerandClaude Opus 4.8 f6a3c527f4 chore(plan): file T-458 EOL/EOF fidelity, T-459 split-collapse bug
Both filed under the T-276 UI epic.

T-458: the editor records neither the original EOL style nor the
trailing-newline state when it reads a file (registry.dart), and only
normalizes on save when .editorconfig asks. Any layer that rewrites
newlines therefore produces cross-platform commit churn with no
.gitattributes backstop. Ticket captures the record-on-read /
preserve-on-save / gitattributes plan.

T-459: closing the last editor panel in the main column's top split
does not collapse it. Suspect the asymmetric top/bottom split logic in
slot_host.dart and closeEditor() not moving the active tab off
editor.active. Root cause not yet confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:05 +02:00
jpmschweitzerandClaude Opus 4.8 57a25e6c2d chore(release): cut 2.6.0
Bump pubspec 2.5.0 -> 2.6.0 and move the Unreleased entries under a dated
2.6.0 heading. Minor (not patch) — the cut includes new features (vim
ex-line + gt/gT, crash-survivable logging, watchdog) alongside the fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:07:17 +02:00
jpmschweitzerandClaude Opus 4.8 804bba1680 docs(design): settings-UI wireframes + settings-ui implementation epic (T-302, T-444)
Close T-302 with the Frame0 wireframes for the schema-driven settings UI, and
open the implementation epic T-444 under the Tier-6 epic T-8.

Wireframes (docs/design/wireframes/settings/, JSON source + PNG):
- settings-screen — modal shell + Editor category (all field-type patterns,
  scope tags, carded sections)
- settings-search — cross-category search-active state
- settings-claude — mirrors the sidebar Claude Config panel (settings controls
  + carded config lists)
- settings-appearance — theme-picker swatch grid (live bundled-theme previews)

Design answers: full-screen MODAL overlay; rail + cross-category search IA;
per-field scope-tag model (folder/globe/circle-dashed); carded sections; the
schema-driven renderer makes per-category tabs data, not new design.

Also documents the sectioned-card preference in the ui-design skill
(references/surface.md → "Settings & grouped lists — sectioned cards").

Epic T-444 children: infra (modal shell T-445, rail T-447, field renderer
T-448, scope-tag control T-449, search T-450) + per-category (Editor=T-290
reparented, Keymap T-451, Appearance/theme-picker T-452, Activity T-453,
Terminal T-455, Extensions T-456, Claude T-457).

Also files: T-441/T-442 (UI bugs), T-446 (slash-typeahead intermittent),
T-454 (Claude remote-control not plumbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:41:55 +02:00
jpmschweitzerandClaude Opus 4.8 b5146cc9ad chore(plan): relabel local T-440 -> T-443 (duplicate across clones)
The push race created two T-440s: the remote tree-sitter.dll task and our
local Playwright-e2e follow-up. Kept the already-shared remote one as T-440;
renumbered our local one to T-443 via pql ticket relabel (record_id stable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:18:39 +02:00
jpmschweitzer 7fa9c168b9 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.pql/changelog/ticket_idmap/2026-06.sql
#	.pql/changelog/tickets/2026-06.sql
2026-06-16 12:17:15 +02:00
jpmschweitzerandClaude Opus 4.8 9b4ee63db4 chore(plan): activate T-302 (picked up for the settings wireframe)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:12:07 +02:00
jpmschweitzerandClaude Opus 4.8 2e4f87455a feat(vim): gt / gT cycle workspace tabs (T-405 part 2)
Closes out the T-403 cross-pane vim layer. gt/gT bind to the existing
workspace.tab.next/previous commands (also on ctrl+pagedown/up for every
preset), resolved by the focused editor's matcher or a pane's PaneKeyNav —
bare-g sequences stay editor/pane-local (T-404's global matcher only engages
on modified-chord prefixes), so no global-matcher surgery.

- vim.yaml: g t -> command:workspace.tab.next, g shift+t -> .previous
  (vim.normal); shares the `g` prefix with `g g` (docStart / nav.top),
  distinguished by the final chord.
- PaneKeyNav now EXECUTES non-editor.vim.* command intents (e.g.
  workspace.tab.*) instead of swallowing all command intents, so gt/gT work
  from a focused pane; editor.vim.* buffer edits stay blocked in panes.

Tests: vim-preset resolution (gt/gT bind, gg intact), PaneKeyNav executes
the command from a pane, and editor.vim.* is never run from a pane. make
test green; analyze + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:08:07 +02:00
jpmschweitzerandClaude Opus 4.8 3a59d5be8a chore(plan): close T-407 (ex-line overlay; review skipped per user)
User opted to skip the live GUI review; improvement tickets will be filed
if anything needs polish. Completes all built children of T-403; only
T-405 part 2 (gt/gT) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:57:28 +02:00
jpmschweitzerandClaude Opus 4.8 e54d5263e0 feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)
Under the Vim preset, `:` opens a transient one-line ex overlay running a
fixed v1 table; ZZ runs :wq directly. Completes the last built child of the
T-403 cross-pane vim layer (T-405 part 2 gt/gT still open).

- ExLineController + parseExCommand grammar + editor-targeted executors
  (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay
  .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and
  publishes the exline.open scope flag. Unknown commands flash + stay open;
  with no active buffer every command no-ops (2026-06-13 decision).
- :q closes the active tab via editor.close on its id — the registry promotes
  the next buffer and the split self-collapses on the last (2026-06-12
  decision); :w/:wq/:x/ZZ save (+close) the active buffer.
- :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the
  editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync:
  EditorController now handles editor.selection-changed and the editor view
  moves the caret on a selection-only change.
- `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble
  unhandled typed intents to the app-root Actions, so they fire from any focus.
  vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss.

Tests: parser/controller/executors, editor.goto-line daemon tests,
selection-changed (controller + view), full overlay widget test. make test
green; analyze + format clean.

Also files T-441 (drop bold from the ticket-id card label) and T-442
(sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:44:48 +02:00
dehlakandClaude dfcfeed8b1 file T-440: build + vendor tree-sitter.dll for Windows
Split the Windows-DLL slice out of T-25 (cross-OS libtree-sitter
build). The FFI loader already resolves tree-sitter.dll on Windows,
but no Windows binary is vendored — only the Linux .so — so
tree-sitter highlighting silently falls back to plain text on
Windows. T-440 is the narrow task to build and vendor that DLL now;
CI reproducibility and the macOS .dylib stay on the T-25 umbrella.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-15 19:03:33 +02:00
jpmschweitzerandClaude Opus 4.8 c5e54ee0ac chore(plan): close T-439 (login-shell PATH verified via Linux menu launch)
Acceptance gap confirmed closed: a make-install build launched from the
Linux application menu (not a terminal) resolves the login-shell PATH —
pql/git/claude/PTY children find user-installed tools. macOS Homebrew
path remains code-correct; a separate bug will be filed if it surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:03:19 +02:00
jpmschweitzerandClaude Opus 4.8 02950cd853 chore(plan): close T-437 (/clear fix verified live in GUI)
Live-confirmed on CLI 2.1.177: /clear clears the primary pane to an
empty conversation without exiting code 1. The await-death teardown fix
(081678e) holds in the running app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:59:36 +02:00
jpmschweitzerandClaude Opus 4.8 ffcf17a136 chore(plan): close T-438 (web fence done); file T-440 (Playwright e2e follow-up)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:57:49 +02:00
jpmschweitzerandClaude Opus 4.8 03d053274e fix(env): one login-shell-derived PATH for every spawned tool (T-439)
Desktop/dock-launched clide inherits a sparse PATH (no ~/.local/bin, brew,
nvm, …), so pql/git/claude and PTY tools went missing. T-347 fixed only the
toolchain/pql path on Linux; env.dart's expander was still macOS-only and
claude/PTY/git used the raw PATH — the breakage recurred per spawn site
because there were three divergent expanders.

Consolidate into one resolver (lib/src/env/shell_env.dart):
- primeLoginShellPath(): probe the user's real login shell once at startup
  (`$SHELL -l -c`, sentinel-framed, bounded timeout, graceful fallback to the
  process PATH). Captures the user's actual PATH, not a hardcoded guess.
- expandToolPath(): the canonical merge (moved from toolchain_paths, which
  re-exports it for its tests) — unions the well-known user/local bin dirs.
- resolvedToolPath(): currentSearchPath() + expandToolPath, the single call
  every spawn site uses.

Routed through it: PTY children (registry.dart now overrides PATH), git
(env.dart → operations.dart), the toolchain probe (toolchain_paths), and
hosted claude (agent_bootstrap). Primed in main.dart's !kIsWeb boot. Deleted
the macOS-only env.dart copy and the cli_install copy.

Tests: new shell_env_test (probe + every fallback + merge); env_test and
cli_install_test updated to the consolidated surface. analyze clean, web wasm
build still green, make test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:46:11 +02:00
jpmschweitzerandClaude Opus 4.8 ca08c2a17d feat(web): fence dart:ffi behind web stubs so the WASM build compiles (T-438, D-100)
`flutter build web --wasm` had been broken since the tree-sitter/PTY dart:ffi
pivot. Per D-100 (resolving Q-50: keep the web "happy accident" alive), every
native binding now sits behind a `dart.library.ffi` conditional import with a
graceful web stub. Desktop builds are unchanged — no fidelity loss; the web
target degrades (no terminal, native git, or syntax highlighting).

Discriminator is `dart.library.ffi`, not `dart.library.io` — dart2wasm provides
dart:io, so FFI is the only blocker.

Fences:
- PTY: pty_session → pty_backend_io / pty_backend_web (stub throws).
- tree-sitter: pure types → syntax_result.dart; tree_sitter_service is now a
  facade over _ffi/_stub; tree_sitter_boot_io/stub fences TreeSitterLib.init().
- watchdog: watchdog_windows_stub (all -1 sampler).
- claude ABI probe: native_abi_io/stub (was `dart:ffi show Abi`).
- testmode fd-check: fd_check_io/stub.

Also dart2js-safe: the 64-bit FNV literals in session_naming.dart + paths.dart
(the dual JS fallback rejected them) — split into 32-bit halves, dropped a
no-op 64-bit mask. Desktop/wasm hash values unchanged.

CI: added a `web-wasm` job (flutter build web --wasm) so the fence can't rot.
Two FFI-constructing tree-sitter tests import _ffi.dart directly (the analyzer
resolves the conditional facade to the stub branch).

Verified: `flutter build web --wasm` → built; `flutter analyze` clean;
`make test` green. Full Playwright e2e harness wiring is the tracked follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:31:24 +02:00
jpmschweitzerandClaude Opus 4.8 967db2f8d9 chore(governance): sync DQR index for D-100 / D-32 / Q-50
Regenerated governance/README.md index (pql decisions sync): D-32
retitled, D-100 added, Q-50 moved to Resolved. Belongs with 4bba2ef.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:36:53 +02:00
jpmschweitzerandClaude Opus 4.8 4bba2ef2f0 D-100 (fence dart:ffi) + amend D-32 (GitHub Actions); close T-384
- D-32 amended: Gitea-primary/not-activated → GitHub Actions, active
  (Linux test + Windows ConPTY/soak + release; web-WASM e2e withheld).
  The staged Gitea pipeline was never activated and is gone.
- Q-50 resolved → D-100: FENCE the web/WASM target. Every dart:ffi
  importer goes behind a conditional-import facade with a web stub so
  `flutter build web --wasm` compiles; desktop fidelity untouched. Keeps
  the web "happy accident" alive as a hopeful future target per user.
- T-438 filed for the fence implementation (12 ffi importers + CI wasm
  compile gate + re-enable e2e/ui targets).
- T-384 closed (done): Gitea premise OBE, scripts repointed (2026-06-12),
  D-32 reconciled; the dead e2e targets delegated to T-438.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:29:11 +02:00
jpmschweitzerandClaude Opus 4.8 081678e2f7 fix(claude): /clear no longer kills the session on CLI 2.1.177 (T-437)
/clear tore the session down and respawned on the same deterministic
--session-id BEFORE the old claude process had actually exited. The
orchestrator's close() ran conversation.dispose() unawaited and kill()
only sent SIGTERM without awaiting exitCode, so the respawn raced a
still-alive holder of the id — claude 2.1.177 rejects it as "Session ID
… is already in use" and exits 1.

Root cause confirmed from clide's own crash log + isolated probes against
2.1.177: the id frees the instant the holder dies (SIGTERM cleans the new
~/.claude/sessions/<pid>.json registry), so awaiting real death is the
fix — and it preserves T-268's deterministic-id continuity (chosen over
minting a fresh id, which would change the continuity model).

- stream_json_session: kill() awaits exitCode (SIGTERM → 2s → SIGKILL →
  await); dispose() idempotent (shared cached future); new
  SessionEnd.reason getter (last non-empty stderr line, capped).
- session_orchestrator: close() awaits session.dispose() so teardown
  returns only once the process is truly dead, before clear + respawn.
- claude_pane: surface end.reason in the status line — no more opaque
  "code 1".
- session_naming: correct the stale clearSessionTranscript doc (real
  sidecar is the shared memory/ dir) + the await-death precondition.
- tests: close() blocks until process exit; SessionEnd.reason.

CLI 2.1.177 re-probe (folded-in scope): sessions/ registry characterized
(PID-keyed, cleaned on exit); init cache auto-refreshes; advertised
slash_commands show no routing-table drift. No further code change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:32:02 +02:00
jpmschweitzerandClaude Opus 4.8 708d4c6f95 T-437: fold the CLI 2.1.177 re-probe into scope + acceptance
The init-cache/routing-table refresh and sessions/ registry
characterization are now explicit deliverables of T-437, not a
separate follow-up, with updated acceptance criteria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:31:55 +02:00
jpmschweitzerandClaude Opus 4.8 8f962226a0 file T-437: /clear kills the Claude session (exit 1) on CLI 2.1.177
Regression of T-268's /clear mechanism. /clear deletes the transcript and
respawns with the same deterministic --session-id, but claude 2.1.177 now
tracks session ids in ~/.claude/sessions/<pid>.json (+history.jsonl) beyond
the per-project transcript clide purges, so the id reads as in-use and
claude exits 1 at startup validation. Codebase only probed <=2.1.175.

Hypothesis is strong but unconfirmed: the pane shows an opaque "code 1" and
swallows claude's stderr — capturing it is fix step 1. Filed high with
ranked fixes (mint fresh id on clear; or clean the registry; surface stderr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:23:09 +02:00
203 changed files with 8411 additions and 1718 deletions
+4 -1
View File
@@ -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 .)"
]
}
}
+7 -3
View File
@@ -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
+8 -1
View File
@@ -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)
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
```
## Settings & grouped lists — sectioned cards
Settings surfaces and any long grouped list (e.g. the Claude config lists)
read as **sectioned cards**, not bare rows floating on the panel. Each logical
group gets its own card; the small-caps section label (+ optional count) sits
just **above** the card.
```
panel bg → panelBackground (#20202C)
card surface → surface (#242838) fill + dividerColor/border (1px), ~6px corners
section head → sidebarSectionHeader (small-caps), with the count muted to its right
control inset → inputs INSIDE a card recede to panelBackground, so they still
read as fields against the elevated card
```
- **One card per group** — a settings table, each config list. The card's
elevated fill + border do the visual separation; don't rely on spacing alone.
- **Field row inside a card:** label (`globalForeground`) + help
(`globalTextMuted`) + the control right-aligned, with the per-field scope tag
in the far-right column.
- **Scroll, don't cram:** when stacked cards exceed the modal/pane viewport, the
pane scrolls vertically (sticky header, scrolling body) — prefer that over
shrinking content to fit one screen.
- Pattern reference: the settings wireframes under
`docs/design/wireframes/settings/` (T-302).
## Anti-patterns
- `globalBackground` for panel fill → use `panelBackground`
- Bare settings rows on the panel where a group of them should be one card →
see "Settings & grouped lists".
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
- Tab active border = `globalFocus` → use `panelActiveBorder`
+15 -7
View File
@@ -114,13 +114,21 @@ jobs:
path: ${{ github.workspace }}/clide-logs
if-no-files-found: ignore
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is
# unavailable on the wasm target). Whether the web target gets conditional-
# import fences or is dropped is an open question — see Q-50 in
# governance/questions/architecture.md. Re-add the job (setup-node, npm
# install + playwright install in tools/ui, `make test-e2e`) when Q-50
# resolves toward keeping it.
web-wasm:
name: web build (wasm compile gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
# Compile gate for the dart:ffi web fence (D-100 / T-438, resolving Q-50):
# every native binding lives behind a `dart.library.ffi` conditional import
# with a web stub. If a new one lands without its stub, this fails — the
# fence can't silently rot. Build-only for now; the full web-WASM Playwright
# e2e (`make test-e2e`: setup-node + playwright install in tools/ui +
# serve) is the follow-on once the harness is wired back up.
- run: flutter build web --wasm
docs:
name: dart doc (lib API)
+842
View File
@@ -5088,3 +5088,845 @@ 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 ('06FCEP642C8ZZ1T20RXQQ3143M', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'cce7ddffbd88447be319407f9cc7ce91', 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 ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'f9a33140c03031c10ca45fecb2edb277', 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 ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'status', 'backlog', 'done', NULL, '2026-06-15 10:34:31', '2026-06-15 10:34:31', '2026-06-15 10:34:31', NULL, 'b9cda82863c37a365e7aa5fc8230256a', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', NULL, '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).', NULL, '2026-06-15 11:12:40', '2026-06-15 11:12:40', '2026-06-15 11:12:40', NULL, '8d437ab6db22ffa01ac237821bf446c5', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', NULL, '2026-06-15 11:30:26', '2026-06-15 11:30:26', '2026-06-15 11:30:26', NULL, '2ab7f67318159b7b9a8b26eeefa2ed8d', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 11:38:50', '2026-06-15 11:38:50', '2026-06-15 11:38:50', NULL, 'c0d2e8bddc695f3cc0afdfd728aed3a6', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 11:40:04', '2026-06-15 11:40:04', '2026-06-15 11:40:04', NULL, 'd858bb1bd89be62a0d00d564229a2517', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'description', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) — /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).', '**Symptom.** Typing `/clear` in the primary Claude pane kills the session: the pane shows `claude exited (code 1) /clear to restart` and the "Warming up — your conversation will appear here." banner, instead of clearing to a fresh empty conversation. Screenshot taken in workspace `/var/mnt/data/projects/settled-reach`.
**Environment.** `claude` CLI **2.1.177** (`~/.local/bin/claude`). The codebase''s init-probe cache and the last probe task only cover **2.1.175** the CLI has moved past what clide last characterized. This is almost certainly a CLI-version regression, not a clide code change.
**`/clear` code path (read-only trace, 2026-06-15):**
- `lib/builtin/claude/src/slash_commands.dart:42` `clear` is in `kClideOwnedCommands`; clide handles it natively (T-156), does not forward it.
- `lib/builtin/claude/src/claude_pane.dart:651-659` `_clearSession()`: primary pane `_respawnWithSession(primarySessionId(root), clearTranscript: true)`.
- `claude_pane.dart:680-703` `_respawnWithSession()`: kills the old session (`activeSessionOrchestrator.close(_orchId)`), then `clearSessionTranscript(...)`, then `_spawn()`.
- `lib/builtin/claude/src/session_naming.dart:109-114` `clearSessionTranscript()` deletes ONLY `~/.claude/projects/<munged-root>/<id>.jsonl` and the sidecar `<id>/` dir.
- `session_naming.dart:75` respawn uses `claudeLaunchArgs(id, resume: false)` = `[''--session-id'', <id>]`, where `<id>` is the **same deterministic `primarySessionId(root)`** (derived from the repo path, `session_naming.dart:68`).
- `session_naming.dart:72-74` (the load-bearing comment): "`--session-id` REFUSES an id that already exists (''Session ID … is already in use'')."
**Root cause (strong hypothesis see caveat).** T-268 built `/clear` on the assumption that the per-project transcript file (`<id>.jsonl` + sidecar) is the *only* thing the CLI uses to decide whether a `--session-id` is "in use". The current CLI tracks session ids in **additional** state beyond that transcript:
- `~/.claude/sessions/<pid>.json` a live-session registry. Confirmed contents (2.1.177): `{"pid":,"sessionId":"e7dad3cf-…","cwd":"/var/mnt/data/projects/clide","version":"2.1.177","status":"idle",}`. Keyed by PID, carries the clide session UUID + cwd.
- `~/.claude/history.jsonl` (2.8 MB, append-only).
So `/clear` deletes the transcript and respawns with the *same deterministic* `--session-id`, but the killed session''s id is still registered (the registry entry is keyed by the now-dead PID and is the CLI''s own state — clide''s purge doesn''t touch it, and an abrupt kill leaves no chance for the CLI to clean it). The new `claude` rejects the id as already-in-use and **exits 1 at startup validation, before any model turn**. Reusing a fixed deterministic id is inherently brittle against the CLI adding new id-tracking surfaces.
**Caveat not yet pinned.** The actual `claude` stderr was NOT captured: the pane only surfaces `exited (code 1)`, swallowing the CLI''s error string. The above is inferred from (a) the documented `--session-id` refusal, (b) the confirmed new registry, (c) the version gap. It must be confirmed against the real stderr before the fix is chosen. **That clide shows an opaque "code 1" with no underlying reason is itself a defect** (see fix #3).
**Fix directions (ranked):**
1. **Stop reusing a fixed `--session-id` on clear.** Mint a fresh id (as secondary panes already do) and persist it as the pane''s *current* primary id, decoupling "current primary session" from the path-derived default so cross-restart continuity (T-268''s goal) survives without colliding. Robust against any CLI-internal id tracking — the right long-term fix and aligns with D-75 (avoid version-pinned coupling to CC internals).
2. **Make the old id reusable before respawn:** graceful-shutdown the old process (SIGTERM, give the CLI a chance to clean its `sessions/<pid>.json`) and/or sweep `~/.claude/sessions/*.json` for entries whose `sessionId == target` before reuse. Fragile — reaches into CLI private state; D-75 caution.
3. **Surface `claude`''s stderr/exit reason in the pane** (and logs) so "code 1" is never opaque again. Do this regardless of 1/2 it''s what makes this diagnosable.
**Also:** re-probe the CLI for 2.1.177 and refresh the init cache / the slash-command routing table (T-410/T-411 territory) the `~/.claude/sessions/` registry is new behavior worth characterizing; other session-lifecycle assumptions may have shifted too.
**Diagnostic step for the fixer:**
1. Reproduce `/clear` in a primary pane with CLI 2.1.177.
2. Capture the spawned `claude`''s stderr/stdout on the failed respawn (the `--session-id <id>` invocation). Confirm whether it is "Session ID … is already in use" vs another error.
3. Inspect `~/.claude/sessions/*.json` immediately after the kill — does an entry with the target `sessionId` linger?
**Files a fix would touch:** `claude_pane.dart` (`_clearSession` 651-659, `_respawnWithSession` 680-703, exit-status handler ~423), `session_naming.dart` (`claudeLaunchArgs` 75, `clearSessionTranscript` 109-114, id derivation 68), `session_orchestrator.dart` (`_spawn` 219-283, `close`), `slash_commands.dart` (routing).
**Related:** T-268 (done built the delete-transcript-then-reuse-`--session-id` mechanism that just regressed), T-156 (clide-owned `/clear`), T-161 (`--resume` vs `--session-id` selection), D-77 (stream-json session model), D-75 (version-pinned coupling to CC internals — the risk this realizes).
---
**Folded-in scope (2026-06-15): CLI 2.1.177 re-probe part of this ticket, not a follow-up.**
Beyond the `/clear` fix, T-437 also owns bringing clide''s CLI characterization up to the running version:
- Re-run the stream-json `initialize` probe against `claude` **2.1.177** and refresh the per-version init cache (`~/.config/clide/claude/init-<version>.json`, T-151). The codebase was last probed at 2.1.175 (probe task + `kClideOwnedCommands`/routing assumptions).
- Refresh the slash-command routing table (T-410/T-411) from the new probe: re-confirm advertised vs TUI-only vs owned for 2.1.177, so nothing regresses to a raw harness error.
- Characterize the new `~/.claude/sessions/<pid>.json` live-session registry (fields, lifecycle: when written, updated, removed esp. whether a killed PID''s entry is cleaned). This directly informs which `/clear` fix is viable.
- Audit other session-lifecycle assumptions that may have drifted with the registry addition (resume/fork id handling, `--session-id` vs `--resume` selection).
**Acceptance (updated):** `/clear` clears the primary pane to an empty conversation on CLI 2.1.177 without exiting; `claude` stderr/exit reason is surfaced in the pane + logs; init cache + routing table refreshed for 2.1.177; the `sessions/` registry lifecycle documented in this ticket (or a D-record if it changes a decision).
---
**RESOLUTION (2026-06-15) root cause confirmed, fixed.**
**Actual cause (confirmed against real stderr, NOT the original collision-vs-registry guess).** Captured from clide''s own crash log (`~/.local/state/clide/logs/clide.log`):
`session primary exited (code 1); stderr tail: Error: Session ID <id> is already in use.`
But the "in use" is a **lifecycle race**, not a persistent registry/transcript collision. Isolated probes against claude 2.1.177 proved:
- `--session-id` reuse SUCCEEDS once the prior holder is dead — even immediately after SIGTERM (the `~/.claude/sessions/<pid>.json` entry is cleaned on a SIGTERM exit), and even with another *live* duplicate in `-p` mode.
- A transcript-absent id never collides.
The only way real clide hit "already in use": the **old process was still alive** at respawn. `ClaudeSessionOrchestrator.close()` called `conversation.dispose()` (unawaited) and `ClaudeStreamJsonProcess.kill()` sent SIGTERM **without awaiting `exitCode`** so `/clear` deleted the transcript and respawned on the same deterministic `--session-id` while the old `claude` was still alive (and re-flushing its transcript). 2.1.177 then rejects the id → exit 1.
**Fix shipped (chose await-death over the ticket''s tentative "mint a fresh id").** Awaiting the old process''s real death is the targeted root-cause fix and *preserves T-268''s deterministic-id continuity* (fresh-id would have required persisting a per-repo current-id and changing the continuity model). Changes:
- `stream_json_session.dart`: `kill()` now awaits `exitCode` (SIGTERM 2s SIGKILL await); `dispose()` is idempotent (cached future) so the conversation''s unawaited dispose and the orchestrator''s awaited one share one teardown; new `SessionEnd.reason` getter (last non-empty stderr line, capped).
- `session_orchestrator.dart`: `close()` awaits `session.dispose()` returns only once the process is truly dead, so the transcript clear + respawn happen after death.
- `claude_pane.dart`: `_onSessionEnd` surfaces `end.reason` in the status line no more opaque "code 1" (fix #3).
- `session_naming.dart`: corrected the stale `clearSessionTranscript` doc (the real sidecar is the shared `memory/` dir, not `<id>/`; left untouched) + the await-death precondition.
- Tests: `close()` blocks until process exit (gated fake); `SessionEnd.reason` extraction/cap/empty.
**Folded-in CLI 2.1.177 re-probe done, no code change needed.**
- `~/.claude/sessions/<pid>.json` registry characterized: PID-keyed, carries `{sessionId, cwd, version, kind:"interactive", entrypoint:"sdk-cli", status}`; written at start, **removed on a SIGTERM/clean exit**. So once clide awaits death, the id is free.
- Init cache (`init-<version>.json`) auto-refreshes at runtime per version nothing to hand-edit.
- Probed 2.1.177 advertised `slash_commands` (31): no drift that breaks the routing table none of clide''s `kTuiOnlyCommands` became advertised, and new entries (skills/builtins) correctly fall through to `forward`.
**Verification status.** Unit-tested (the teardown ordering + reason surfacing) and validated against the live CLI via probes. `make test` green. NOT yet exercised in the running GUI (would need `make run` + an interactive `/clear`) recommend a quick live confirm before closing.', NULL, '2026-06-15 13:30:11', '2026-06-15 13:30:11', '2026-06-15 13:30:11', NULL, 'ca1b8cc05faf1bf385494030cbc9eb2f', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'in_progress', 'review', NULL, '2026-06-15 13:30:21', '2026-06-15 13:30:21', '2026-06-15 13:30:21', NULL, '2f16ac8313a7e627166ae842ba7c096c', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', NULL, 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', NULL, '2026-06-15 14:22:06', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, 'ab5a072e75e46831da1980d36292b84a', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'description', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.', 'tools/ui/*.sh still cd into the removed app/ directory, so make test-e2e, make ui-dev, and make ui-smoke fail immediately. The staged Gitea CI workflow would fail in three independent ways the day it is activated, while D-32 describes it as ready.
Fix: repoint the scripts at the repo root (post app/-flattening layout), run each target to prove it, and walk the Gitea workflow steps locally (or in a dry-run) until each step is green or consciously removed. Amend D-32 if the CI story changed.
Acceptance: all three make targets run; the workflow file''s steps each map to a working make target; D-32 matches reality.
2026-06-12: mechanical fixes done tools/ui/build.sh and serve.sh repointed at the repo root (post app/-flattening), and the Gitea workflow rewritten to go through make targets with a real coverage run before the coverage gate (it previously cd''d into the removed app/ in every job AND ran coverage_gate with no coverage data). Verified: make ui-dev now reaches the real compiler. Which exposed the deeper break: flutter build web --wasm cannot compile the tree at all — dart:ffi (tree-sitter pivot, native PTY) is unavailable on the wasm target. Whether to fence, park, or drop the web/Playwright surface is a user decision → Q-50 (governance/questions/architecture.md). The workflow''s e2e job is withheld with a pointer to Q-50; make test-e2e/ui-dev/ui-smoke remain blocked on it. Leaving this ticket in review until Q-50 resolves.
---
**Closed 2026-06-15 reconciled.**
The Gitea premise is overtaken by events: CI migrated to **GitHub Actions** (`8e0f33b` Linux, `45aa2d9` Windows/release) the staged Gitea workflow this bug was about was never activated and is gone (only `legacy/.gitea/` remains). Resolution of the three acceptance criteria:
1. *Workflow steps map to working targets* done by removal: no Gitea workflow exists; the GitHub `test.yml` goes through `make` targets and withholds the web-WASM e2e job with a Q-50 pointer.
2. *`tools/ui` scripts repointed off the removed `app/`* done 2026-06-12 (`41af83f`).
3. *D-32 matches reality* done now: D-32 amended to "GitHub Actions, active".
The one live remnant the dead `make test-e2e` / `ui-dev` / `ui-smoke` targets was a pending **decision**, now made: Q-50 resolved **fence** (D-100). The actual fencing implementation (and re-enabling the e2e targets + CI job) is tracked in **T-438**. Nothing left to do under this ticket.', NULL, '2026-06-15 14:25:07', '2026-06-15 14:25:07', '2026-06-15 14:25:07', NULL, '2c0ef2348b5b496eb67035f42cad5d5f', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'review', 'done', NULL, '2026-06-15 14:25:10', '2026-06-15 14:25:10', '2026-06-15 14:25:10', NULL, 'fba93f08e50b81ab6c3c4b5d1dd4f1d9', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 14:32:22', '2026-06-15 14:32:22', '2026-06-15 14:32:22', NULL, '80f8ce4f94230fe7c842bd25e1b7f131', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 14:33:11', '2026-06-15 14:33:11', '2026-06-15 14:33:11', NULL, '783dee14da908512f766c8d9b0351e1d', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', NULL, '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', NULL, '2026-06-15 15:23:47', '2026-06-15 15:23:47', '2026-06-15 15:23:47', NULL, 'c5a27e255cd62d26167231269985ba39', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, '0cf096a810a7a075307c8df8321b5c33', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, 'a7d81444607b6ec3aa8440fcbc9abee3', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 15:31:15', '2026-06-15 15:31:15', '2026-06-15 15:31:15', NULL, '5fed82be61479c8191ed8d0663507cad', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 15:32:16', '2026-06-15 15:32:16', '2026-06-15 15:32:16', NULL, 'c1e48da490b71dea20c50a486600b643', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, ) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` `expandedPath` **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` `_expandedPath()` augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` `expandedPath(base, {macOS, home})` a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.
---
**Implemented 2026-06-15.** One shared resolver `lib/src/env/shell_env.dart`:
- `primeLoginShellPath()` probes `$SHELL -l -c` once at startup (sentinel-framed, 4s timeout, graceful fallback to the process PATH on Windows/empty-SHELL/non-zero/timeout/spawn-failure). Primed in `main.dart`''s `!kIsWeb` boot.
- `expandToolPath()` the canonical user/local-dir merge (moved from toolchain_paths, which re-exports it for its tests).
- `resolvedToolPath()` the single call every spawn site uses.
Routed through it: PTY children (`registry.dart` overrides PATH), git (`env.dart` `operations.dart`), the toolchain probe (`toolchain_paths.dart`), hosted claude (`agent_bootstrap.dart`). Deleted the macOS-only `env.dart` expander and the `cli_install.dart` copy three expanders one.
Verified: `flutter analyze` clean; new `shell_env_test.dart` covers the probe + every fallback + the merge; `env_test`/`cli_install_test` updated; `make test` green; `flutter build web --wasm` still green (resolver is web-safe, probe is `!kIsWeb`).
**Why `review`, not `done`:** logic + tests cover Linux and macOS, but the real *desktop/dock-launch* behavior (the actual GUI process inheriting a sparse PATH) can only be confirmed by launching a packaged build from the launcher recommend a quick live confirm (open clide from the dock, check pql/git/a `~/.local/bin` tool resolve). macOS Homebrew path is code-correct but unverified on a Mac.', NULL, '2026-06-15 15:47:48', '2026-06-15 15:47:48', '2026-06-15 15:47:48', NULL, 'feaf3593dabcb0b7d7681043dddddbb3', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:47:49', '2026-06-15 15:47:49', '2026-06-15 15:47:49', NULL, '9b7ed7d84e12574200900604143e866c', 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 ('06FCQZ47MAN835B215GSSMRV8W', 'description', NULL, 'Follow-up from T-438 (the `dart:ffi` web fence, D-100): with `flutter build web --wasm` compiling again and a compile gate in CI, restore the *full* web-WASM Playwright e2e — the remaining slice of T-438''s acceptance.
**What''s already done (T-438).** The web build compiles; `.github/workflows/test.yml` has a `web-wasm` job that runs `flutter build web --wasm` (the anti-rot compile gate); `make ui-dev` (build + serve) works.
**What this ticket adds.** The actual browser e2e, which needs runner provisioning the compile gate doesn''t:
- `make test-e2e` / `make ui-smoke` run green locally and in CI (build serve `localhost:4280` Playwright smoke teardown).
- A GitHub Actions job: `setup-node`, `npm install` + `npx playwright install --with-deps` in `tools/ui/`, then `make test-e2e`. Add it to `.github/workflows/test.yml` (replacing the compile-only `web-wasm` job, or as a second job that `needs` it).
- Confirm the Playwright driver (D-26) still matches the current web entrypoint after the fence (degraded web build: no terminal/native-git/highlighting the smoke should assert what *does* render, e.g. the shell boots and a pane mounts).
**Acceptance.** `make test-e2e` and `make ui-smoke` pass locally; a CI job runs the Playwright smoke against the wasm build on every push/PR; D-26/D-32 reflect the restored e2e job.
**Refs:** T-438 (compile fence + gate), D-100 / Q-50 (fence decision), D-26 (Playwright driver), D-32 (CI; the withheld e2e job).', NULL, '2026-06-15 15:53:35', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '14d8eb1f3bc639cb4ce3cddb5179a853', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi real impl; web stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.
Closed 2026-06-15: the dart:ffi fence + flutter build web --wasm compile gate are landed, verified (ca08c2a), and on origin/main. The remaining acceptance slice — the full web-WASM Playwright e2e harness wired into CI (make test-e2e / ui-smoke + the node/playwright runner job) — is split out to T-440, since it needs runner provisioning separate from the fence itself.', NULL, '2026-06-15 15:53:42', '2026-06-15 15:53:42', '2026-06-15 15:53:42', NULL, '0b238079758d9882647e0a2fcec7cbe7', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'review', 'done', NULL, '2026-06-15 15:53:47', '2026-06-15 15:53:47', '2026-06-15 15:53:47', NULL, '799f878cdf6c8f6b9256fb852b4c4537', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'status', 'review', 'done', NULL, '2026-06-15 15:59:15', '2026-06-15 15:59:15', '2026-06-15 15:59:15', NULL, '96e17294ee3609fa7f2dd9a69fa38fa6', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'review', 'done', NULL, '2026-06-15 16:03:07', '2026-06-15 16:03:07', '2026-06-15 16:03:07', NULL, '75bf3f398e16ec94668b9d7937c33a4e', 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 ('06FB3DMF20SYFDT6WX2RFBQXKW', 'status', 'in_progress', 'backlog', NULL, '2026-06-15 16:14:56', '2026-06-15 16:14:56', '2026-06-15 16:14:56', NULL, '085d54bd54f253b684cea7fd61638cee', 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 ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'description', NULL, 'The ticket-id label at the top of each kanban card (e.g. "T-403") is rendered in semibold and is hard to read at the small card font size — user feedback 2026-06-16 (screenshot: ~/.cache/clide/pasted/paste-1781601250588.png).
**Fix:** drop the bold weight from the id label. lib/builtin/tickets/src/tickets_view.dart:405 `ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600)` remove `fontWeight: FontWeight.w600` so it renders at the default UI weight. Confirm the parent-id breadcrumb line (the muted ` T-NNN` above a child, ~line 385-395) still reads fine; keep the title (line 409) as-is.
Scope: cosmetic weight tweak only. Verify against the four presets/themes; no golden churn expected beyond the tickets-view widget golden if one exists.', NULL, '2026-06-16 09:16:18', '2026-06-16 09:16:18', '2026-06-16 09:16:18', NULL, '3b414a2fe4ba72623f59d3e461082bf7', 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 ('06FCZGG38FF8T9ADF945Z0XPMG', 'description', NULL, 'User report 2026-06-16 (screenshot ~/.cache/clide/pasted/paste-1781601988494.png, observed in the settled-reach session). An Agent/Task tool interaction renders as THREE sibling top-level cards instead of one bundled, collapsed card:
1. `> Agent Implement T-1039+T-1043 dispatch threading` 1 step, </> badge (the Agent tool-use collapser)
2. `> agent run Edit //server/src/atlas/plugin.rs` 65 steps
3. `> agent prompt You are DUDLEY, server developer`
EXPECTED (per T-305 collapser + T-338 sidechain fold): the sub-agent''s run + prompt + its 65 steps should be bundled INSIDE the single Agent collapser card, not surfaced as siblings. The ''1 step'' on the Agent card next to a separate ''65 steps'' card is the tell the step count that belongs to the agent landed on a different card than its work.
QUESTIONS TO RESOLVE:
- Is the three-card split a regression in the sidechain fold, or are ''agent run''/''agent prompt'' a distinct (hosted-agent) render path that never went through the collapser? Determine which.
- The stray/inconsistent left indent on these cards (user also flagged) confirm and remove so the bundle reads as one unit.
CODE POINTERS: lib/builtin/claude/src/activity_cluster.dart (isAgentTool: Task|Agent, line 83); lib/builtin/claude/src/transcript_reader.dart (isSidechain / parentToolUseId fold, ~line 571-575; subagents/agent-<id>.jsonl, line 10); lib/builtin/claude/src/conversation_view.dart (card rendering). Check whether ''agent run''/''agent prompt'' items carry parentToolUseId and whether the fold groups them under the Agent tool_use id.
Scope: diagnose first (bug vs two-render-paths), then bundle the sub-agent interaction into the single collapser + fix the indent. Widget test for the folded sub-agent card.', NULL, '2026-06-16 09:27:53', '2026-06-16 09:27:53', '2026-06-16 09:27:53', NULL, '8a6a6a533fd388d09a31a1f23af55fa5', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.
IMPLEMENTED 2026-06-16. Built the ex command-line overlay end to end. Architecture chose the QuickOpen precedent over the ticket''s tentative vim-builtin layout (cleaner, no extension/ctx plumbing):
- ExLineController + parseExCommand grammar + editor-targeted executors in lib/kernel/src/ex_line.dart (controller mirrors QuickOpenController; executors go through editor IPC verbs).
- ExLineOverlay in lib/widgets/src/ex_line_overlay.dart, mounted in the root_shell Stack beside QuickOpenOverlay; publishes the exline.open scope flag; rejected commands flash a red border + "Not an editor command" hint and stay open (no timer test-friendly).
- v1 table: :w (editor.save, active-default), :q (editor.close active id; registry promotes next / collapses split on last, per the 2026-06-12 decision), :wq/:x and ZZ (save+close), :e <path> (quickOpen.open(seed:) new seed param), :N (new editor.goto-line IPC/CLI verb reusing _offsetForLine). No-active-buffer = no-op (2026-06-13 decision). Unknown = flash + stay open.
- Routing: ":" and ZZ are TYPED intents (ExLineOpenIntent/ExLineWriteQuitIntent). The editor''s _dispatchVim and PaneKeyNav now BUBBLE unhandled typed intents to the app-root Actions (small generalization), so they fire whether the editor, a pane, or nothing is focused. editor.vim.* stays swallowed in panes.
- Goto-line needed caret sync: EditorController now handles editor.selection-changed and editor_view moves the caret on a selection-only change (the content-gated branch previously skipped it).
- vim.yaml: ":" -> exline.open (vim.normal); ZZ (shift+z shift+z) -> exline.writeQuit (vim.normal); Esc -> dismiss (exline.open).
Tests: parser/controller/executors (ex_line_test), editor.goto-line daemon tests, editor.selection-changed controller + view tests, full overlay widget test (each row, unknown-hint, dismiss, scope flag). make test green; analyze + format clean. CHANGELOG updated.
NOT yet exercised in the running GUI recommend a quick live confirm (vim preset: :w / :q / :42 / :e / ZZ / a bad command) before closing.
This completes the ex-line. T-405 part 2 (gt/gT) remains the only open T-403 child and still needs the bare-g design decision (see T-405).', NULL, '2026-06-16 09:42:34', '2026-06-16 09:42:34', '2026-06-16 09:42:34', NULL, 'c53afd4a1354012e36081b84dab66bfd', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'ready', 'review', NULL, '2026-06-16 09:42:39', '2026-06-16 09:42:39', '2026-06-16 09:42:39', NULL, 'e2cc00b9f1b80080e17e0213079400f1', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'review', 'done', NULL, '2026-06-16 09:56:18', '2026-06-16 09:56:18', '2026-06-16 09:56:18', NULL, 'e0ea178ba163bf3395c21793c8cf9a14', 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 ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'ready', 'done', NULL, '2026-06-16 10:07:56', '2026-06-16 10:07:56', '2026-06-16 10:07:56', NULL, '4b0584f96faa90f0a715dde390c64008', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'in_progress', 'done', NULL, '2026-06-16 10:08:00', '2026-06-16 10:08:00', '2026-06-16 10:08:00', NULL, 'a7ee8d073cb004d8f3c53a7128cc3aef', 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', 'done', 'ready', NULL, '2026-06-16 10:08:46', '2026-06-16 10:08:46', '2026-06-16 10:08:46', NULL, '6589fa3057a9c2d009805d1044988f43', 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', 'ready', 'in_progress', NULL, '2026-06-16 10:09:07', '2026-06-16 10:09:07', '2026-06-16 10:09:07', NULL, '01cb8766df16793a9290d5ae0e0b4c25', 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 ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'description', NULL, 'Implement the schema-driven settings UI (`builtin.settings-ui`) designed in T-302. Wireframes (JSON source + PNG): `docs/design/wireframes/settings/` — settings-screen (Editor), settings-search, settings-claude, settings-appearance.
**Form factor / IA (decided in T-302):** a centered MODAL overlay over the dimmed app (modal* / modalOverlay* tokens; close + Esc), opened by a `settings.open` command. Left category rail + a cross-category search box; right scrolling panel of CARDED sections each logical group is a card (surface fill + 1px border, ~6px corners) with a small-caps section label + count above it. See the new `ui-design` `surface.md` "Settings & grouped lists — sectioned cards".
**Engine, not per-screen UI:** field rows render from the schema each subsystem registers against the kernel SettingsStore toggle / enum-select / text-number / "opens external file" affordance each with label, help, default, reset, and a per-field SCOPE TAG (folder = project `.clide` / globe = always `~/.clide` / circle-dashed = default-unset; colour + tooltip; tap scope menu). Edits write back to the resolved scope''s settings file. Categories are DATA; the only custom control is Appearance''s theme picker.
**Decisions / consumers:** kernel SettingsStore schema; WidgetsApp-only tokens (D-7); keymap presets (T-115 / T-64 / T-65 / T-66); editor settings (T-290 / T-29); activity fold (T-183); theming (existing builtin.theme-picker, D-44 / D-69); Claude config mirrors `lib/builtin/claude/src/meta_sidebar/config_tab.dart` (T-414 / T-183). Phosphor glyphs resolve via `byName(...)` (T-314) no consts to add.
**Children build infra first, then categories extrapolate:**
Infra: modal shell + `settings.open`; category rail + nav; schema field renderer (the engine); scope-tag control + scope resolution; cross-category search.
Categories (register a schema, reuse the engine): Editor (T-290), Keymap, Appearance (theme-picker control), Activity, Terminal, Extensions, Claude (mirror the sidebar Config panel).', NULL, '2026-06-16 11:13:12', '2026-06-16 11:13:12', '2026-06-16 11:13:12', NULL, 'a197764303d8a520a601166822524506', 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 ('06FD09DH88J1659FDDYJ83JH1M', 'description', NULL, 'User report 2026-06-16: typing `/` in the Claude composer **intermittently** does not open the slash-command typeahead dialog. Non-deterministic — sometimes it opens, sometimes the `/` just types with no popover.
Two hypotheses to distinguish (the fix depends which):
1. **Handler not triggering on `/`** the composer''s keypress/text-change detection that should fire the typeahead misses the `/` (focus/IME race, the `/` not at a word boundary, the listener not firing on that change). If so, the popover never opens regardless of list contents.
2. **Empty/incomplete list** the handler fires but the command list is empty or still loading (the slash-command set from the CLI probe / routing table not yet populated typeahead opens with nothing and dismisses, or suppresses on empty). If so, it correlates with session warm-up / probe timing.
**Diagnosis first:** add a log at the trigger point (did `/` fire the open?) and at list-resolution (how many entries?) to see which path fails when it reproduces. Likely correlates with session/probe readiness (intermittent timing).
**Files:** lib/builtin/claude/src/claude_composer.dart (the composer + typeahead trigger on `/`), lib/builtin/claude/src/slash_commands.dart (the command set / routing table, kClideOwnedCommands / kTuiOnlyCommands), the ClideTypeahead primitive (slash typeahead was migrated onto it). Compare with the `@`-mention typeahead (at_commands.dart), which shares the primitive does `@` reproduce the same intermittency? That isolates primitive vs slash-specific.
**Acceptance:** `/` reliably opens the typeahead on every invocation (cold session + warm); if the list isn''t ready yet, open with a loading/empty state rather than silently not opening; a regression test around the trigger + empty-list path.', NULL, '2026-06-16 11:17:44', '2026-06-16 11:17:44', '2026-06-16 11:17:44', NULL, 'ccec4493b58264e0921ce0670e6fbe13', 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', 'description', NULL, '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).', NULL, '2026-06-16 11:17:54', '2026-06-16 11:17:54', '2026-06-16 11:17:54', NULL, '71d80701bc0f7a46d2a58a543143ff5a', 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', 'description', NULL, '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.', NULL, '2026-06-16 11:18:16', '2026-06-16 11:18:16', '2026-06-16 11:18:16', NULL, '0ed6ee0b39a849c77fa8773a83576f40', 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', 'description', NULL, '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).', NULL, '2026-06-16 11:18:27', '2026-06-16 11:18:27', '2026-06-16 11:18:27', NULL, '25283a37d7f7450ed7f53c5c9578382c', 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', 'description', NULL, '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.', NULL, '2026-06-16 11:18:41', '2026-06-16 11:18:41', '2026-06-16 11:18:41', NULL, 'a70401823b69148f142ac32844ece558', 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', 'description', NULL, '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.', NULL, '2026-06-16 11:18:51', '2026-06-16 11:18:51', '2026-06-16 11:18:51', NULL, '63bdec18d4c8933b8c9c491d1bdec82b', 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 ('06FB0TNQM44CKN5QQGFKP1KWZC', 'parent_id', 'T-8', 'T-444', NULL, '2026-06-16 11:19:03', '2026-06-16 11:19:03', '2026-06-16 11:19:03', NULL, '80eef9832e6ef2048aed0b02bbfc2765', 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', 'description', NULL, '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).', NULL, '2026-06-16 11:19:35', '2026-06-16 11:19:35', '2026-06-16 11:19:35', NULL, '1378cada399c128b9a3f5c45721c6088', 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', 'description', NULL, '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.', NULL, '2026-06-16 11:19:47', '2026-06-16 11:19:47', '2026-06-16 11:19:47', NULL, 'e4d1aa3170384cb44c6099528db76ac0', 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', 'description', NULL, 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', NULL, '2026-06-16 11:20:21', '2026-06-16 11:20:21', '2026-06-16 11:20:21', NULL, '658bd31d93101ce02bdbf34871d2d032', 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 ('06FD0ABC7QNEC3XCTV73YPSGR4', 'description', NULL, 'User report 2026-06-16: Claude Code''s **remote-control** feature (start / monitor / steer a running Claude Code session remotely — e.g. from claude.ai or the mobile app) is **not plumbed into clide**.
clide hosts the `claude` CLI as a stream-json child (`--session-id` / `--resume`, D-77). For remote control to work through clide, a clide-hosted session needs to be discoverable + steerable by the remote-control surface the same way a terminal-launched `claude` is.
**Step 1 characterize (don''t assume the mechanism):** determine exactly what Claude Code exposes for remote control on the running CLI version. Likely touch points:
- the `~/.claude/sessions/<pid>.json` live-session registry we characterized in T-437 (PID-keyed: sessionId, cwd, version, kind, entrypoint, status) is this what the remote-control surface enumerates? clide-spawned sessions DO write an entry (we saw `entrypoint: sdk-cli`). Confirm whether clide''s entry is visible/controllable remotely, or whether `entrypoint: sdk-cli` / headless spawn excludes it.
- any flag / capability in the `initialize` stream-json probe (T-411/T-151 cache) advertising remote control.
- whether enrollment requires the interactive TUI (and so is suppressed under clide''s stream-json spawn).
**Step 2 plumb:** wire clide so a clide-hosted session participates in remote control (register correctly / pass the needed flag), and surface its state in the UI (e.g. an indicator that the session is remote-controllable / being driven remotely). If it conflicts with clide''s session ownership (pane pins a session-id, T-156), document the boundary.
**Open question:** is the goal (a) clide sessions become remotely controllable from claude.ai/mobile, or (b) clide can act as a remote controller of other sessions? Default read is (a). Confirm with the user before building.
Related: D-77 (stream-json session model), T-437 (sessions registry characterization), T-156 (clide owns session lifecycle), T-411 (capability probe).', NULL, '2026-06-16 13:20:17', '2026-06-16 13:20:17', '2026-06-16 13:20:17', NULL, 'b5428fe4b6cfec075693b19f46d0203d', 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 ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'description', NULL, 'Register the terminal settings schema (font family/size, scrollback, cursor style, bell, copy-on-select, etc.) as standard toggle / number / select fields via the field renderer (T-448).', NULL, '2026-06-16 13:20:34', '2026-06-16 13:20:34', '2026-06-16 13:20:34', NULL, '6ed2b34154ae47dc645f570889ea8c48', 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', '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 (LFCRLF) 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 + 12 others; mono: JetBrainsMono + 12 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;
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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'status', 'backlog', 'done', NULL, '2026-06-17 20:51:21', '2026-06-17 20:51:21.006', '2026-06-17 20:51:21.006', NULL, '20516cde17cc921f419aa5f12ea49bc1', 2) ON CONFLICT(hash) DO NOTHING;
+39
View File
@@ -261,3 +261,42 @@ 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 ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 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 ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 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 ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 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 ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 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 ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 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 ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 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 ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 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 ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 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 ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 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 ('06FCRERT7X5WMZSGKQCA6T0VB4', 'T-440', '2026-06-15 17:01:25', '2026-06-15 17:01:25', NULL, 'b8e152bf87ec8d81d1bddeb023f37883', 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 ('06FCQZ47MAN835B215GSSMRV8W', 'T-443', '2026-06-15 15:53:05', '2026-06-16 10:17:57', NULL, 'a4d4390596d24d7dc28d80861e36d66b', 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 ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'T-444', '2026-06-16 11:07:54', '2026-06-16 11:07:54', NULL, 'ec7a0d8d01c984d931855a6e5c4e86ec', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'T-445', '2026-06-16 11:16:20', '2026-06-16 11:16:20', NULL, '418d561d50d37c3dd9b6e3abe5fc1b1f', 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 ('06FD09DH88J1659FDDYJ83JH1M', 'T-446', '2026-06-16 11:16:31', '2026-06-16 11:16:31', NULL, '7c0bc42be1a170b5bb2dd8bd7b027d69', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'T-447', '2026-06-16 11:18:10', '2026-06-16 11:18:10', NULL, '203dfb0348a94392f2d087b79aee655f', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'T-448', '2026-06-16 11:18:20', '2026-06-16 11:18:20', NULL, '765a91fb5b51889861a41f7eea308ea6', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'T-449', '2026-06-16 11:18:32', '2026-06-16 11:18:32', NULL, '1dbd17ff345551d3f091a226bb0f7164', 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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'T-450', '2026-06-16 11:18:45', '2026-06-16 11:18:45', NULL, '56f97ccefa83681c387ee9cc4ca6a477', 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 ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'T-451', '2026-06-16 11:19:11', '2026-06-16 11:19:11', NULL, 'c329f6f1a5d205b7bdf5ab682812a6d1', 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 ('06FD0A4MY8DF6NB0KNV96RQADR', 'T-452', '2026-06-16 11:19:41', '2026-06-16 11:19:41', NULL, '71a7729a8cf7ebc4e915a9e2736717bd', 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 ('06FD0A6K9B60ADTFBZA6NBVJFM', 'T-453', '2026-06-16 11:19:57', '2026-06-16 11:19:57', NULL, 'd408d563785166ee1b40e7494c7f3bbc', 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 ('06FD0ABC7QNEC3XCTV73YPSGR4', 'T-454', '2026-06-16 11:20:36', '2026-06-16 11:20:36', NULL, '27e2ae5b0897eb0f589f4617061101a9', 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 ('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);
+6
View File
@@ -1,2 +1,8 @@
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'multi-window', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'ce03f8eb534bc45e2c1c12e9b30a2c30', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'ipc', '2026-06-14 15:29:28', '2026-06-14 15:29:28', NULL, 'e6789b66db5c1de85daf6fc8d3474449', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'web', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '4cbf5007b12087b88b8acb36f3616bf8', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
File diff suppressed because it is too large Load Diff
+134
View File
@@ -18,6 +18,114 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
### Changed
### Fixed
## [2.7.1] — 2026-06-18
### Changed
- **Josefin Sans is the default UI font again.** Reverts the Inter default from
2.7.0; Inter stays bundled and selectable in Settings → Appearance. JetBrains
Mono remains the default monospace face (Fira Mono selectable). (T-460)
### Fixed
- **Markdown prose honours the UI-font setting.** Claude's conversation prose
and inline links render through the markdown engine, which pinned the bundled
UI face and ignored the Appearance UI-font pick; it now follows the setting
live, like the rest of the app. (T-475)
## [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
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
commands flash and stay open; with no active buffer it no-ops. Opens from the
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
verb. (T-407)
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
@@ -40,6 +148,32 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
and `clide log level [<level>]` does the same from the CLI — D-6 parity. The
choice survives restart. (T-433)
### Fixed
- **`/clear` no longer kills the Claude pane.** Clearing the primary pane tore
the session down and respawned on the same deterministic `--session-id`
*before the old `claude` process had actually exited*, so claude 2.1.177
rejected the id as "already in use" and the respawn exited 1. The session
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
before clearing the transcript and respawning. A dead pane also now shows the
CLI's own reason instead of a bare "exited (code 1)". (T-437)
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
web target degrades (no terminal, no native git, no syntax highlighting), it
does not compromise desktop fidelity. (T-438, D-100, Q-50)
- **Desktop-launched clide finds your installed tools.** A dock/launcher start
inherits a sparse `PATH` (no `~/.local/bin`, brew, nvm, …), so pql/git/claude
and terminal tools could go missing. clide now resolves the real login-shell
`PATH` once at startup (`$SHELL -l -c`, bounded + graceful fallback) and routes
every spawn site — PTY children, git, the toolchain probe, hosted claude —
through one shared resolver, replacing three divergent (and partly macOS-only)
PATH expanders. (T-439, follows T-347)
## [2.5.0] — 2026-06-14
### Added
+2
View File
@@ -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
View File
@@ -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.
+6 -6
View File
@@ -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.
+3 -3
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+30
View File
@@ -50,6 +50,11 @@ bindings:
- intent: dismiss
keys: escape
when: quickOpen.open
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
# overlay's own EditableText handles Enter via onSubmitted).
- intent: dismiss
keys: escape
when: exline.open
- intent: findInFiles.open
keys: [ctrl+shift+f, meta+shift+f]
- intent: focus.nextPanel
@@ -128,6 +133,31 @@ bindings:
keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual"
# ---- Tab motions (T-405) ---------------------------------------------
# gt / gT cycle the workspace tab strip via the preset-neutral
# workspace.tab.* commands (also on ctrl+pagedown/up everywhere). Bare-`g`
# sequences are editor/pane-local — the focused editor's matcher or a pane's
# PaneKeyNav resolves them and runs the command; they share the `g` prefix
# with `g g` (docStart / nav.top), distinguished by the final chord.
- intent: command:workspace.tab.next
keys: g t # gt
when: vim.normal
- intent: command:workspace.tab.previous
keys: g shift+t # gT
when: vim.normal
# ---- Ex command-line (T-407) -----------------------------------------
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
# matcher and a pane's nav matcher bubble to the app-root Actions, so they
# fire from any focus. Editor-targeted: no-op when no buffer is active.
- intent: exline.open
keys: shift+semicolon # :
when: vim.normal
- intent: exline.writeQuit
keys: shift+z shift+z # ZZ
when: vim.normal
# ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual
keys: v
+27 -4
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.5.0"
version: "2.7.1"
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: >-
Selectable application UI face (bundled T-460); Josefin Sans is the
default. Variable font with optical-size and weight axes.
weights_bundled: [VariableFont, Italic-VariableFont]
- name: Josefin Sans
kind: font
version: "variable"
@@ -65,8 +87,9 @@ 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.
Default application UI face; default weight Light (300), full 100-700
range via the variable-font weight axis. Inter is bundled as a
selectable alternative.
weights_bundled: [VariableFont, Italic-VariableFont]
- name: Phosphor Icons
-776
View File
@@ -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, 15, 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.
@@ -0,0 +1,120 @@
{
"name": "Settings — Appearance",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance-sel": { "type": "Rectangle", "left": 130, "top": 228, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-appearance-stripe": { "type": "Rectangle", "left": 130, "top": 228, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Theme, contrast, and text size.", "fontColor": "#78809C", "fontSize": 13 },
"grp-theme": { "type": "Text", "left": 382, "top": 186, "text": "THEME", "fontColor": "#78809C", "fontSize": 12 },
"theme-scope-body": { "type": "Rectangle", "left": 1004, "top": 188, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"theme-scope-tab": { "type": "Rectangle", "left": 1004, "top": 185, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"s1-card": { "type": "Rectangle", "left": 382, "top": 210, "width": 200, "height": 92, "fillColor": "#20202C", "strokeColor": "#78A0F8", "corners": [6, 6, 6, 6] },
"s1-side": { "type": "Rectangle", "left": 390, "top": 218, "width": 22, "height": 76, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [3, 3, 3, 3] },
"s1-bar": { "type": "Rectangle", "left": 418, "top": 218, "width": 156, "height": 14, "fillColor": "#242838", "strokeColor": "#242838", "corners": [3, 3, 3, 3] },
"s1-accent": { "type": "Rectangle", "left": 418, "top": 240, "width": 40, "height": 4, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"s1-l1": { "type": "Rectangle", "left": 418, "top": 254, "width": 120, "height": 4, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
"s1-l2": { "type": "Rectangle", "left": 418, "top": 266, "width": 90, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"s1-l3": { "type": "Rectangle", "left": 418, "top": 278, "width": 110, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"s1-check-bg": { "type": "Ellipse", "left": 558, "top": 218, "width": 16, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"s1-check": { "type": "Text", "left": 561, "top": 219, "text": "✓", "fontColor": "#20202C", "fontSize": 11 },
"s1-name": { "type": "Text", "left": 390, "top": 306, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
"s2-card": { "type": "Rectangle", "left": 598, "top": 210, "width": 200, "height": 92, "fillColor": "#1E1E1E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s2-side": { "type": "Rectangle", "left": 606, "top": 218, "width": 22, "height": 76, "fillColor": "#181818", "strokeColor": "#181818", "corners": [3, 3, 3, 3] },
"s2-bar": { "type": "Rectangle", "left": 634, "top": 218, "width": 156, "height": 14, "fillColor": "#252526", "strokeColor": "#252526", "corners": [3, 3, 3, 3] },
"s2-accent": { "type": "Rectangle", "left": 634, "top": 240, "width": 40, "height": 4, "fillColor": "#569CD6", "strokeColor": "#569CD6" },
"s2-l1": { "type": "Rectangle", "left": 634, "top": 254, "width": 120, "height": 4, "fillColor": "#D4D4D4", "strokeColor": "#D4D4D4" },
"s2-l2": { "type": "Rectangle", "left": 634, "top": 266, "width": 90, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
"s2-l3": { "type": "Rectangle", "left": 634, "top": 278, "width": 110, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
"s2-name": { "type": "Text", "left": 606, "top": 306, "text": "midnight", "fontColor": "#E6E8F2", "fontSize": 13 },
"s3-card": { "type": "Rectangle", "left": 814, "top": 210, "width": 200, "height": 92, "fillColor": "#F4F1EA", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s3-side": { "type": "Rectangle", "left": 822, "top": 218, "width": 22, "height": 76, "fillColor": "#ECE7DB", "strokeColor": "#ECE7DB", "corners": [3, 3, 3, 3] },
"s3-bar": { "type": "Rectangle", "left": 850, "top": 218, "width": 156, "height": 14, "fillColor": "#FBF8F1", "strokeColor": "#FBF8F1", "corners": [3, 3, 3, 3] },
"s3-accent": { "type": "Rectangle", "left": 850, "top": 240, "width": 40, "height": 4, "fillColor": "#C14B2A", "strokeColor": "#C14B2A" },
"s3-l1": { "type": "Rectangle", "left": 850, "top": 254, "width": 120, "height": 4, "fillColor": "#1A1A1A", "strokeColor": "#1A1A1A" },
"s3-l2": { "type": "Rectangle", "left": 850, "top": 266, "width": 90, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
"s3-l3": { "type": "Rectangle", "left": 850, "top": 278, "width": 110, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
"s3-name": { "type": "Text", "left": 822, "top": 306, "text": "paper", "fontColor": "#E6E8F2", "fontSize": 13 },
"s4-card": { "type": "Rectangle", "left": 382, "top": 330, "width": 200, "height": 92, "fillColor": "#0C0C0C", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s4-side": { "type": "Rectangle", "left": 390, "top": 338, "width": 22, "height": 76, "fillColor": "#000000", "strokeColor": "#000000", "corners": [3, 3, 3, 3] },
"s4-bar": { "type": "Rectangle", "left": 418, "top": 338, "width": 156, "height": 14, "fillColor": "#141414", "strokeColor": "#141414", "corners": [3, 3, 3, 3] },
"s4-accent": { "type": "Rectangle", "left": 418, "top": 360, "width": 40, "height": 4, "fillColor": "#FFC868", "strokeColor": "#FFC868" },
"s4-l1": { "type": "Rectangle", "left": 418, "top": 374, "width": 120, "height": 4, "fillColor": "#C8C8C8", "strokeColor": "#C8C8C8" },
"s4-l2": { "type": "Rectangle", "left": 418, "top": 386, "width": 90, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
"s4-l3": { "type": "Rectangle", "left": 418, "top": 398, "width": 110, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
"s4-name": { "type": "Text", "left": 390, "top": 426, "text": "terminal", "fontColor": "#E6E8F2", "fontSize": 13 },
"s5-card": { "type": "Rectangle", "left": 598, "top": 330, "width": 200, "height": 92, "fillColor": "#1E1E2E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s5-side": { "type": "Rectangle", "left": 606, "top": 338, "width": 22, "height": 76, "fillColor": "#181825", "strokeColor": "#181825", "corners": [3, 3, 3, 3] },
"s5-bar": { "type": "Rectangle", "left": 634, "top": 338, "width": 156, "height": 14, "fillColor": "#313244", "strokeColor": "#313244", "corners": [3, 3, 3, 3] },
"s5-accent": { "type": "Rectangle", "left": 634, "top": 360, "width": 40, "height": 4, "fillColor": "#CBA6F7", "strokeColor": "#CBA6F7" },
"s5-l1": { "type": "Rectangle", "left": 634, "top": 374, "width": 120, "height": 4, "fillColor": "#CDD6F4", "strokeColor": "#CDD6F4" },
"s5-l2": { "type": "Rectangle", "left": 634, "top": 386, "width": 90, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
"s5-l3": { "type": "Rectangle", "left": 634, "top": 398, "width": 110, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
"s5-name": { "type": "Text", "left": 606, "top": 426, "text": "catppuccin-mocha", "fontColor": "#E6E8F2", "fontSize": 13 },
"s6-card": { "type": "Rectangle", "left": 814, "top": 330, "width": 200, "height": 92, "fillColor": "#21262F", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"s6-side": { "type": "Rectangle", "left": 822, "top": 338, "width": 22, "height": 76, "fillColor": "#292E38", "strokeColor": "#292E38", "corners": [3, 3, 3, 3] },
"s6-bar": { "type": "Rectangle", "left": 850, "top": 338, "width": 156, "height": 14, "fillColor": "#393E48", "strokeColor": "#393E48", "corners": [3, 3, 3, 3] },
"s6-accent": { "type": "Rectangle", "left": 850, "top": 360, "width": 40, "height": 4, "fillColor": "#FA5F8B", "strokeColor": "#FA5F8B" },
"s6-l1": { "type": "Rectangle", "left": 850, "top": 374, "width": 120, "height": 4, "fillColor": "#E2E8F5", "strokeColor": "#E2E8F5" },
"s6-l2": { "type": "Rectangle", "left": 850, "top": 386, "width": 90, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
"s6-l3": { "type": "Rectangle", "left": 850, "top": 398, "width": 110, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
"s6-name": { "type": "Text", "left": 822, "top": 426, "text": "summer-night", "fontColor": "#E6E8F2", "fontSize": 13 },
"grp-options": { "type": "Text", "left": 382, "top": 462, "text": "OPTIONS", "fontColor": "#78809C", "fontSize": 12 },
"card-options": { "type": "Rectangle", "left": 382, "top": 482, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"hc-label": { "type": "Text", "left": 398, "top": 500, "text": "High contrast", "fontColor": "#E6E8F2", "fontSize": 15 },
"hc-track": { "type": "Rectangle", "left": 950, "top": 498, "width": 40, "height": 22, "fillColor": "#343850", "strokeColor": "#343850", "corners": [11, 11, 11, 11] },
"hc-knob": { "type": "Ellipse", "left": 952, "top": 500, "width": 18, "height": 18, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
"hc-scope-circle": { "type": "Ellipse", "left": 1004, "top": 502, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"hc-scope-eq": { "type": "Rectangle", "left": 1004, "top": 508, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"hc-scope-mer": { "type": "Rectangle", "left": 1010, "top": 502, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ts-label": { "type": "Text", "left": 398, "top": 544, "text": "Text size", "fontColor": "#E6E8F2", "fontSize": 15 },
"ts-box": { "type": "Rectangle", "left": 926, "top": 542, "width": 64, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"ts-text": { "type": "Text", "left": 940, "top": 547, "text": "100%", "fontColor": "#E6E8F2", "fontSize": 14 },
"ts-scope-circle": { "type": "Ellipse", "left": 1004, "top": 546, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"ts-scope-eq": { "type": "Rectangle", "left": 1004, "top": 552, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ts-scope-mer": { "type": "Rectangle", "left": 1010, "top": 546, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ct-label": { "type": "Text", "left": 398, "top": 588, "text": "Custom theme", "fontColor": "#E6E8F2", "fontSize": 15 },
"ct-button": { "type": "Rectangle", "left": 846, "top": 588, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
"ct-button-text": { "type": "Text", "left": 858, "top": 595, "text": "Import theme YAML ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
"ct-scope-circle": { "type": "Ellipse", "left": 1004, "top": 592, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"ct-scope-eq": { "type": "Rectangle", "left": 1004, "top": 598, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"ct-scope-mer": { "type": "Rectangle", "left": 1010, "top": 592, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,127 @@
{
"name": "Settings — Claude",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 1250, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 1222, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 1160, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 1160, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 1114, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 1114, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-claude-sel": { "type": "Rectangle", "left": 130, "top": 264, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-claude-stripe": { "type": "Rectangle", "left": 130, "top": 264, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"scrollbar-track": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 1104, "fillColor": "#242838", "strokeColor": "#242838", "corners": [2, 2, 2, 2] },
"scrollbar-thumb": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 250, "fillColor": "#3C445C", "strokeColor": "#3C445C", "corners": [2, 2, 2, 2] },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Model, effort, permissions, and the workspace's loaded skills, agents & MCP.", "fontColor": "#78809C", "fontSize": 13 },
"grp-settings": { "type": "Text", "left": 382, "top": 186, "text": "SETTINGS", "fontColor": "#78809C", "fontSize": 12 },
"card-settings": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 166, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"model-label": { "type": "Text", "left": 398, "top": 222, "text": "Model", "fontColor": "#E6E8F2", "fontSize": 15 },
"model-select": { "type": "Rectangle", "left": 870, "top": 218, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"model-text": { "type": "Text", "left": 882, "top": 223, "text": "opus-4.8", "fontColor": "#78A0F8", "fontSize": 14 },
"model-chevron": { "type": "Text", "left": 974, "top": 223, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"model-scope-circle": { "type": "Ellipse", "left": 1004, "top": 224, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"model-scope-eq": { "type": "Rectangle", "left": 1004, "top": 230, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"model-scope-mer": { "type": "Rectangle", "left": 1010, "top": 224, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"effort-label": { "type": "Text", "left": 398, "top": 254, "text": "Effort", "fontColor": "#E6E8F2", "fontSize": 15 },
"effort-select": { "type": "Rectangle", "left": 870, "top": 250, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"effort-text": { "type": "Text", "left": 882, "top": 255, "text": "high", "fontColor": "#E6E8F2", "fontSize": 14 },
"effort-chevron": { "type": "Text", "left": 974, "top": 255, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"effort-scope-circle": { "type": "Ellipse", "left": 1004, "top": 256, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"effort-scope-eq": { "type": "Rectangle", "left": 1004, "top": 262, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"effort-scope-mer": { "type": "Rectangle", "left": 1010, "top": 256, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"perm-label": { "type": "Text", "left": 398, "top": 286, "text": "Permission mode", "fontColor": "#E6E8F2", "fontSize": 15 },
"perm-select": { "type": "Rectangle", "left": 870, "top": 282, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"perm-text": { "type": "Text", "left": 882, "top": 287, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
"perm-chevron": { "type": "Text", "left": 974, "top": 287, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"perm-scope-body": { "type": "Rectangle", "left": 1004, "top": 290, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"perm-scope-tab": { "type": "Rectangle", "left": 1004, "top": 287, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"style-label": { "type": "Text", "left": 398, "top": 318, "text": "Output style", "fontColor": "#E6E8F2", "fontSize": 15 },
"style-select": { "type": "Rectangle", "left": 870, "top": 314, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"style-text": { "type": "Text", "left": 882, "top": 319, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
"style-chevron": { "type": "Text", "left": 974, "top": 319, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"style-scope-circle": { "type": "Ellipse", "left": 1004, "top": 320, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"style-scope-eq": { "type": "Rectangle", "left": 1004, "top": 326, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"style-scope-mer": { "type": "Rectangle", "left": 1010, "top": 320, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"source-label": { "type": "Text", "left": 398, "top": 350, "text": "Source", "fontColor": "#8890AC", "fontSize": 15 },
"source-value": { "type": "Text", "left": 870, "top": 350, "text": "~/.claude · .clide", "fontColor": "#545C84", "fontSize": 13 },
"grp-skills": { "type": "Text", "left": 382, "top": 392, "text": "SKILLS", "fontColor": "#78809C", "fontSize": 12 },
"grp-skills-n": { "type": "Text", "left": 1014, "top": 392, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
"card-skills": { "type": "Rectangle", "left": 382, "top": 410, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"sk1": { "type": "Text", "left": 398, "top": 426, "text": "ui-design", "fontColor": "#78A0F8", "fontSize": 13 },
"sk2": { "type": "Text", "left": 398, "top": 448, "text": "frame0-wireframe", "fontColor": "#78A0F8", "fontSize": 13 },
"sk3": { "type": "Text", "left": 398, "top": 470, "text": "pql", "fontColor": "#78A0F8", "fontSize": 13 },
"sk4": { "type": "Text", "left": 398, "top": 492, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
"sk5": { "type": "Text", "left": 398, "top": 514, "text": "git-commit", "fontColor": "#78A0F8", "fontSize": 13 },
"sk6": { "type": "Text", "left": 398, "top": 536, "text": "testmode", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-agents": { "type": "Text", "left": 382, "top": 580, "text": "AGENTS", "fontColor": "#78809C", "fontSize": 12 },
"grp-agents-n": { "type": "Text", "left": 1020, "top": 580, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
"card-agents": { "type": "Rectangle", "left": 382, "top": 598, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"ag1": { "type": "Text", "left": 398, "top": 614, "text": "Explore", "fontColor": "#78A0F8", "fontSize": 13 },
"ag2": { "type": "Text", "left": 398, "top": 636, "text": "Plan", "fontColor": "#78A0F8", "fontSize": 13 },
"ag3": { "type": "Text", "left": 398, "top": 658, "text": "general-purpose", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-commands": { "type": "Text", "left": 382, "top": 702, "text": "COMMANDS", "fontColor": "#78809C", "fontSize": 12 },
"grp-commands-n": { "type": "Text", "left": 1014, "top": 702, "text": "5", "fontColor": "#545C84", "fontSize": 12 },
"card-commands": { "type": "Rectangle", "left": 382, "top": 720, "width": 648, "height": 128, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"cm1": { "type": "Text", "left": 398, "top": 736, "text": "whats-next", "fontColor": "#78A0F8", "fontSize": 13 },
"cm2": { "type": "Text", "left": 398, "top": 758, "text": "code-review", "fontColor": "#78A0F8", "fontSize": 13 },
"cm3": { "type": "Text", "left": 398, "top": 780, "text": "simplify", "fontColor": "#78A0F8", "fontSize": 13 },
"cm4": { "type": "Text", "left": 398, "top": 802, "text": "verify", "fontColor": "#78A0F8", "fontSize": 13 },
"cm5": { "type": "Text", "left": 398, "top": 824, "text": "run", "fontColor": "#78A0F8", "fontSize": 13 },
"grp-hooks": { "type": "Text", "left": 382, "top": 868, "text": "HOOKS", "fontColor": "#78809C", "fontSize": 12 },
"grp-hooks-n": { "type": "Text", "left": 1020, "top": 868, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
"card-hooks": { "type": "Rectangle", "left": 382, "top": 886, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"hk1-ev": { "type": "Text", "left": 398, "top": 902, "text": "SessionStart", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk1-cmd": { "type": "Text", "left": 540, "top": 902, "text": "peon-ping ready", "fontColor": "#8890AC", "fontSize": 12 },
"hk2-ev": { "type": "Text", "left": 398, "top": 924, "text": "UserPromptSubmit", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk2-cmd": { "type": "Text", "left": 540, "top": 924, "text": "peon-ping working", "fontColor": "#8890AC", "fontSize": 12 },
"hk3-ev": { "type": "Text", "left": 398, "top": 946, "text": "Stop", "fontColor": "#B1BBE3", "fontSize": 13 },
"hk3-cmd": { "type": "Text", "left": 540, "top": 946, "text": "peon-ping done", "fontColor": "#8890AC", "fontSize": 12 },
"grp-perms": { "type": "Text", "left": 382, "top": 990, "text": "PERMISSIONS", "fontColor": "#78809C", "fontSize": 12 },
"grp-perms-n": { "type": "Text", "left": 1020, "top": 990, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
"card-perms": { "type": "Rectangle", "left": 382, "top": 1008, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"pm-allow-k": { "type": "Text", "left": 398, "top": 1024, "text": "allow", "fontColor": "#7DD3A8", "fontSize": 12 },
"pm-allow-v": { "type": "Text", "left": 452, "top": 1024, "text": "Bash(git *) · Read · Edit · Write", "fontColor": "#B1BBE3", "fontSize": 13 },
"pm-ask-k": { "type": "Text", "left": 398, "top": 1046, "text": "ask", "fontColor": "#E6C370", "fontSize": 12 },
"pm-ask-v": { "type": "Text", "left": 452, "top": 1046, "text": "Bash(rm *)", "fontColor": "#B1BBE3", "fontSize": 13 },
"pm-deny-k": { "type": "Text", "left": 398, "top": 1068, "text": "deny", "fontColor": "#E87D7D", "fontSize": 12 },
"pm-deny-v": { "type": "Text", "left": 452, "top": 1068, "text": "Bash(curl *)", "fontColor": "#B1BBE3", "fontSize": 13 },
"grp-mcp": { "type": "Text", "left": 382, "top": 1112, "text": "MCP SERVERS", "fontColor": "#78809C", "fontSize": 12 },
"grp-mcp-n": { "type": "Text", "left": 1020, "top": 1112, "text": "2", "fontColor": "#545C84", "fontSize": 12 },
"card-mcp": { "type": "Rectangle", "left": 382, "top": 1130, "width": 648, "height": 62, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"mcp1": { "type": "Text", "left": 398, "top": 1146, "text": "claude.ai", "fontColor": "#E6E8F2", "fontSize": 13 },
"mcp2": { "type": "Text", "left": 398, "top": 1168, "text": "context7", "fontColor": "#E6E8F2", "fontSize": 13 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,89 @@
{
"name": "Settings — Editor",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
"cat-editor-sel": { "type": "Rectangle", "left": 130, "top": 156, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
"cat-editor-stripe": { "type": "Rectangle", "left": 130, "top": 156, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 14 },
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 18 },
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Buffer formatting, indentation, and per-folder overrides.", "fontColor": "#78809C", "fontSize": 13 },
"grp-fmt": { "type": "Text", "left": 382, "top": 186, "text": "FORMATTING", "fontColor": "#78809C", "fontSize": 12 },
"card-fmt": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 268, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r1-label": { "type": "Text", "left": 398, "top": 222, "text": "Format on save", "fontColor": "#E6E8F2", "fontSize": 15 },
"r1-help": { "type": "Text", "left": 398, "top": 246, "text": "Run the formatter on every buffer save.", "fontColor": "#78809C", "fontSize": 13 },
"r1-toggle": { "type": "Rectangle", "left": 950, "top": 224, "width": 40, "height": 22, "fillColor": "#78A0F8", "strokeColor": "#78A0F8", "corners": [11, 11, 11, 11] },
"r1-knob": { "type": "Ellipse", "left": 970, "top": 226, "width": 18, "height": 18, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 228, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 225, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r2-label": { "type": "Text", "left": 398, "top": 288, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
"r2-help": { "type": "Text", "left": 398, "top": 312, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
"r2-select": { "type": "Rectangle", "left": 870, "top": 288, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r2-select-text": { "type": "Text", "left": 882, "top": 293, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
"r2-select-chevron": { "type": "Text", "left": 974, "top": 293, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 292, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 298, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 292, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r3-label": { "type": "Text", "left": 398, "top": 354, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
"r3-help": { "type": "Text", "left": 398, "top": 378, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
"r3-number": { "type": "Rectangle", "left": 934, "top": 354, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r3-number-text": { "type": "Text", "left": 956, "top": 359, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
"r3-scope-body": { "type": "Rectangle", "left": 1004, "top": 358, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r3-scope-tab": { "type": "Rectangle", "left": 1004, "top": 355, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r4-label": { "type": "Text", "left": 398, "top": 420, "text": "Ruler column", "fontColor": "#E6E8F2", "fontSize": 15 },
"r4-help": { "type": "Text", "left": 398, "top": 444, "text": "Wrap-guide position. Unset — inherits the default (120).", "fontColor": "#78809C", "fontSize": 13 },
"r4-number": { "type": "Rectangle", "left": 934, "top": 420, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r4-number-text": { "type": "Text", "left": 950, "top": 425, "text": "120", "fontColor": "#545C84", "fontSize": 14 },
"r4-scope-circle": { "type": "Ellipse", "left": 1004, "top": 424, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#78809C" },
"grp-files": { "type": "Text", "left": 382, "top": 494, "text": "FILE OVERRIDES", "fontColor": "#78809C", "fontSize": 12 },
"card-files": { "type": "Rectangle", "left": 382, "top": 514, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r5-label": { "type": "Text", "left": 398, "top": 530, "text": "Project .editorconfig", "fontColor": "#E6E8F2", "fontSize": 15 },
"r5-help": { "type": "Text", "left": 398, "top": 554, "text": "Per-folder overrides. Opens the file in the editor.", "fontColor": "#78809C", "fontSize": 13 },
"r5-button": { "type": "Rectangle", "left": 846, "top": 530, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
"r5-button-text": { "type": "Text", "left": 858, "top": 537, "text": "Open .editorconfig ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
"r5-scope-body": { "type": "Rectangle", "left": 1004, "top": 534, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r5-scope-tab": { "type": "Rectangle", "left": 1004, "top": 531, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"legend-rule": { "type": "Rectangle", "left": 382, "top": 600, "width": 648, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"legend-title": { "type": "Text", "left": 382, "top": 616, "text": "Scope", "fontColor": "#78809C", "fontSize": 13 },
"lg-proj-body": { "type": "Rectangle", "left": 438, "top": 618, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"lg-proj-tab": { "type": "Rectangle", "left": 438, "top": 615, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"lg-proj-text": { "type": "Text", "left": 462, "top": 614, "text": "folder · Project", "fontColor": "#8890AC", "fontSize": 12 },
"lg-always-circle": { "type": "Ellipse", "left": 600, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#E6C370" },
"lg-always-eq": { "type": "Rectangle", "left": 600, "top": 621, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"lg-always-mer": { "type": "Rectangle", "left": 606, "top": 615, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"lg-always-text": { "type": "Text", "left": 622, "top": 614, "text": "globe · Always", "fontColor": "#8890AC", "fontSize": 12 },
"lg-default-circle": { "type": "Ellipse", "left": 752, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#78809C" },
"lg-default-text": { "type": "Text", "left": 774, "top": 614, "text": "circle-dashed · Default", "fontColor": "#8890AC", "fontSize": 12 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,70 @@
{
"name": "Settings — Search",
"shapes": {
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#78A0F8", "corners": [4, 4, 4, 4] },
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#B1BBE3" },
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#B1BBE3", "strokeColor": "#B1BBE3" },
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "tab", "fontColor": "#E6E8F2", "fontSize": 13 },
"search-caret": { "type": "Rectangle", "left": 198, "top": 123, "width": 1, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
"search-clear": { "type": "Text", "left": 328, "top": 124, "text": "✕", "fontColor": "#78809C", "fontSize": 12 },
"rail-hint": { "type": "Text", "left": 152, "top": 164, "text": "Searching all categories", "fontColor": "#545C84", "fontSize": 12 },
"cat-editor": { "type": "Text", "left": 152, "top": 196, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
"cat-editor-n": { "type": "Text", "left": 326, "top": 196, "text": "2", "fontColor": "#545C84", "fontSize": 13 },
"cat-keymap": { "type": "Text", "left": 152, "top": 228, "text": "Keymap", "fontColor": "#545C84", "fontSize": 14 },
"cat-appearance": { "type": "Text", "left": 152, "top": 260, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
"cat-appearance-n": { "type": "Text", "left": 326, "top": 260, "text": "1", "fontColor": "#545C84", "fontSize": 13 },
"cat-claude": { "type": "Text", "left": 152, "top": 292, "text": "Claude", "fontColor": "#545C84", "fontSize": 14 },
"cat-activity": { "type": "Text", "left": 152, "top": 324, "text": "Activity", "fontColor": "#545C84", "fontSize": 14 },
"cat-terminal": { "type": "Text", "left": 152, "top": 356, "text": "Terminal", "fontColor": "#545C84", "fontSize": 14 },
"cat-extensions": { "type": "Text", "left": 152, "top": 388, "text": "Extensions", "fontColor": "#545C84", "fontSize": 14 },
"results-head": { "type": "Text", "left": 382, "top": 120, "text": "3 settings match “tab”", "fontColor": "#B1BBE3", "fontSize": 14 },
"grp-editor": { "type": "Text", "left": 382, "top": 160, "text": "EDITOR", "fontColor": "#78809C", "fontSize": 12 },
"card-editor": { "type": "Rectangle", "left": 382, "top": 178, "width": 648, "height": 132, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r1-label": { "type": "Text", "left": 398, "top": 194, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
"r1-help": { "type": "Text", "left": 398, "top": 218, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
"r1-number": { "type": "Rectangle", "left": 934, "top": 194, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r1-number-text": { "type": "Text", "left": 956, "top": 199, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 198, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 195, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
"r2-label": { "type": "Text", "left": 398, "top": 260, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
"r2-help": { "type": "Text", "left": 398, "top": 284, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
"r2-select": { "type": "Rectangle", "left": 870, "top": 260, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r2-select-text": { "type": "Text", "left": 882, "top": 265, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
"r2-select-chevron": { "type": "Text", "left": 974, "top": 265, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 264, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 270, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 264, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"grp-appearance": { "type": "Text", "left": 382, "top": 330, "text": "APPEARANCE", "fontColor": "#78809C", "fontSize": 12 },
"card-appearance": { "type": "Rectangle", "left": 382, "top": 348, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
"r3-label": { "type": "Text", "left": 398, "top": 364, "text": "Workspace tab density", "fontColor": "#E6E8F2", "fontSize": 15 },
"r3-help": { "type": "Text", "left": 398, "top": 388, "text": "Spacing of the workspace tab strip.", "fontColor": "#78809C", "fontSize": 13 },
"r3-select": { "type": "Rectangle", "left": 870, "top": 364, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
"r3-select-text": { "type": "Text", "left": 882, "top": 369, "text": "Compact", "fontColor": "#E6E8F2", "fontSize": 14 },
"r3-select-chevron": { "type": "Text", "left": 974, "top": 369, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
"r3-scope-circle": { "type": "Ellipse", "left": 1004, "top": 368, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
"r3-scope-eq": { "type": "Rectangle", "left": 1004, "top": 374, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"r3-scope-mer": { "type": "Rectangle", "left": 1010, "top": 368, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
"footer-note": { "type": "Text", "left": 382, "top": 444, "text": "Results span every category — edit a field inline, or open its category for the full list.", "fontColor": "#545C84", "fontSize": 12 }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+4 -2
View File
@@ -73,7 +73,7 @@ You might also want, project-permitting:
- [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_
- [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_
- [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_
- [D-32: CI — Gitea primary, Linux-only runners, not yet activated](decisions/tooling.md#d-32-ci--gitea-primary-linux-only-runners-not-yet-activated) — _tooling_
- [D-32: CI — GitHub Actions, Linux + Windows runners, active](decisions/tooling.md#d-32-ci--github-actions-linux--windows-runners-active) — _tooling_
- [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_
- [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
@@ -141,6 +141,8 @@ You might also want, project-permitting:
- [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_
- [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
@@ -182,7 +184,6 @@ You might also want, project-permitting:
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
- [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
## Resolved questions
@@ -197,6 +198,7 @@ You might also want, project-permitting:
- [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
## Rejected
+8
View File
@@ -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."
---
+1
View File
@@ -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
+14 -5
View File
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
- **Date:** 2026-04-21
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim.
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared.
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
### D-32: CI — GitHub Actions, Linux + Windows runners, active
- **Date:** 2026-04-21 (amended 2026-06-15)
- **Decision:** CI runs on **GitHub Actions** under `.github/workflows/`: `test.yml` (Linux — analyze + format + unit/widget/golden + coverage gate, with `pql` installed and `pql.db` rebuilt from the changelog), `windows.yml` + `windows-soak.yml` (ConPTY tests + the orphan-leak soak), and `release.yml` (version-tagged builds, the CHANGELOG section as release notes). macOS is tested locally (no macOS runner). The web-WASM Playwright e2e job is withheld pending the `dart:ffi` fence (D-100 / Q-50). Every job goes through `make` targets.
- **Rationale:** The repo moved to GitHub (origin `postmeridiem/clide`); GitHub Actions consumes the same workflow syntax the staged Gitea pipeline used, so the move was near-verbatim. Defining the CI story before flipping it on kept early red builds low-blast-radius.
- **Cost:** macOS coverage is local-only; the browser/e2e surface stays dark until D-100's fence lands.
- **Amended (2026-06-15):** Superseded the original "Gitea primary, not yet activated" posture — the Gitea staging pipeline was never activated and is gone (only `legacy/.gitea/`, the frozen Python tree, remains); CI is live on GitHub Actions (commits `8e0f33b` Linux, `45aa2d9` Windows/release). Reconciled while closing out T-384.
- **Raised by:** 2026-04-21 planning.
### D-42: Dependencies documented in `licenses.yaml`
@@ -101,3 +102,11 @@ Toolchain, supply chain, CI, ignore strategy.
- **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them."
---
### D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling
- **Date:** 2026-06-15
- **Decision:** Resolve [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) by **fencing** (option a): every module that imports `dart:ffi` — the native PTY (`lib/src/pty/`), tree-sitter (`lib/kernel/src/syntax/`), the Lua host (`lib/lua/`), the Windows watchdog, and the FFI `libc` shim — moves behind a conditional-import facade: `import 'x_native.dart' if (dart.library.ffi) 'x_native.dart' ... else 'x_stub.dart'` (io/ffi → native impl; web → a stub). Web stubs degrade gracefully (no PTY/terminal, no native git, no tree-sitter highlighting — the web target is a UI/e2e surface, not a functional desktop replacement) and never throw at import time. A `flutter build web --wasm` compile step is added to CI so the fence can't silently rot.
- **Rationale:** Keep the web/WASM build a live "happy accident" — a hopeful future target the maintainer wants to reach eventually — rather than letting the dart:ffi pivot quietly amputate it (option c, drop) or leaving it dark and rotting (option b, park). Crucially this does **not** compromise desktop fidelity (the CLAUDE.md guardrail): stubs exist only on the web target; the desktop build keeps the real FFI impls unchanged. Fencing is reversible and additive; dropping the target is effectively one-way.
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
+2 -1
View File
@@ -160,7 +160,8 @@ ticket persistence.
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
- **Status:** Open
- **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
+10 -23
View File
@@ -21,11 +21,15 @@
/// IO wrapper the orchestrator calls. Flutter-free by design.
library;
import 'dart:ffi' show Abi;
import 'dart:io';
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
// build gets a default dir name with no FFI introspection.
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
/// hosted session (T-217), so the agent isn't prompted on every IDE call.
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is
@@ -90,26 +94,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
return null;
}
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
/// when clide runs un-installed (dogfooding clide-on-clide).
String nativeClideDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macos today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
/// The result of [agentBootstrap]: the env delta to overlay and the extra
/// spawn args (context note + allow rule) to prepend to a session's argv.
class AgentBootstrap {
@@ -125,10 +109,13 @@ class AgentBootstrap {
/// orchestrator merges both into one `--append-system-prompt`.
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
final home = Platform.environment['HOME'];
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
// shells out to — find user-installed components on a desktop launch, not just
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
final currentPath = resolvedToolPath();
final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin',
'$workspaceRoot/native/${nativeClideDirName()}',
'$workspaceRoot/native/${currentNativeDirName()}',
File(Platform.resolvedExecutable).parent.path,
];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
+5 -2
View File
@@ -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),
],
+1 -2
View File
@@ -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;
+26 -5
View File
@@ -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!);
@@ -420,7 +432,10 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return;
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
// Surface the CLI's own reason (e.g. "Session ID … is already in use")
// instead of an opaque "code 1" (T-437).
final why = end.reason.isEmpty ? '' : '${end.reason}';
setState(() => _statusLine = 'claude exited (code ${end.exitCode})$why · /clear to restart');
}
// Send composed text to Claude over the stream-json channel. Commands clide
@@ -714,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) {
@@ -843,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,
),
);
}
}
+1 -2
View File
@@ -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)),
),
),
),
+14 -5
View File
@@ -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,
+77
View File
@@ -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(
+2 -2
View File
@@ -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),
+28
View File
@@ -0,0 +1,28 @@
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
/// (T-438 web fence, D-100). Desktop-only; the web build uses
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
/// wasm graph.
library;
import 'dart:ffi' show Abi;
import 'dart:io' show Platform;
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
/// un-installed. [abi] is an injection seam for tests; production passes none.
String currentNativeDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macOS today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
@@ -0,0 +1,6 @@
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
/// native dir name only matters for locating a dev-tree `clide` binary, which
/// doesn't exist on the web target — so a harmless default suffices.
library;
String currentNativeDirName() => 'linux-x64';
@@ -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, _) {
+31 -24
View File
@@ -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);
}
+18 -6
View File
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
/// missing entries are not an error. The caller MUST have killed the session's
/// process first, so claude is not mid-write.
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
/// entries are not an error). Note the shared per-project `memory/` dir that
/// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
/// not per-session.
///
/// The caller MUST have AWAITED the session's process death first (T-437): a
/// still-live claude re-flushes its transcript and keeps the id registered, so
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
final file = File('$projectDir/$sessionId.jsonl');
if (await file.exists()) await file.delete();
@@ -124,12 +130,18 @@ String freshSessionId() {
/// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) {
final bytes = <int>[];
var h = 0xcbf29ce484222325;
// FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
// web fallback accepts it (a full 64-bit literal "can't be represented
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
// a session id (no claude process there).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) {
h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
// 64-bit modular wrap is implicit on the VM/wasm; the explicit
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
h = h * prime;
}
bytes.add(h & 0xff);
}
@@ -313,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
}
/// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams.
/// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
/// return (T-437). Callers respawn the primary on the same deterministic
/// `--session-id` right after /clear — if the old process were still alive,
/// claude 2.1.177 would reject the id as "already in use" and the respawn
/// would exit 1.
Future<void> close(String id) async {
final m = _sessions.remove(id);
if (m == null) return;
broker.removeMember(id);
m.conversation.dispose();
m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
await m.session.dispose(); // idempotent — awaits the real process exit
notifyListeners();
}
+1 -2
View File
@@ -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,
+1 -2
View File
@@ -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,
@@ -107,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
@override
Future<void> kill() async {
// Await the process's ACTUAL death, not just the signal (T-437). clide
// respawns the primary on the SAME deterministic --session-id right after
// /clear; if the old process is still alive (or still flushing its
// transcript) when the new one starts, claude 2.1.177 rejects the id with
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
// (claude cleans its session registry on it), escalate to SIGKILL if it
// doesn't go, and only return once exitCode has resolved.
_proc.kill();
try {
await _proc.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
_proc.kill(ProcessSignal.sigkill);
await _proc.exitCode;
}
}
@override
@@ -288,6 +301,20 @@ class SessionEnd {
final int exitCode;
final List<String> stderrTail;
/// The most recent non-empty stderr line — the CLI's own error message when
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
/// stderr was silent; capped so a stray long line can't blow out the status
/// line.
String get reason {
for (final line in stderrTail.reversed) {
final t = line.trim();
if (t.isEmpty) continue;
return t.length > 200 ? '${t.substring(0, 200)}' : t;
}
return '';
}
}
class StreamJsonSession {
@@ -965,10 +992,17 @@ class StreamJsonSession {
_endCtl.add(_end!);
}
Future<void> dispose() async {
/// Idempotent: the conversation controller's [dispose] fires this
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
/// awaits it to know the process is truly dead (T-437). Caching the future
/// makes both paths share one teardown rather than killing/closing twice.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void>? _disposeFuture;
Future<void> _dispose() async {
_disposed = true; // deliberate teardown — suppress the exit-watch path
await _sub?.cancel();
await _proc.kill();
await _proc.kill(); // awaits the process's real exit (T-437)
await _items.close();
await _statusCtl.close();
await _workflowsCtl.close();
@@ -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(
+2 -2
View File
@@ -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)),
),
],
],
+2 -3
View File
@@ -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),
+16 -9
View File
@@ -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,
),
),
],
),
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
_dirty = false;
notifyListeners();
}
case 'editor.selection-changed':
// An external setSelection moved the caret server-side (find-in-files
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
// buffer so the view's caret follows. Skipped while our own local edits
// are in flight — their echo already carries the authoritative caret.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
final sel = e.data['selection'];
if (sel is Map) {
_selection = Selection.fromJson(sel.cast<String, Object?>());
notifyListeners();
}
}
case 'editor.settings-changed':
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
// settings. Refresh the active buffer's copy so indent/ruler update.
+23 -4
View File
@@ -108,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
final c = _controller!;
_syncTabs(c);
_text.updatePath(c.activePath);
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content;
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
_text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged);
} else if (sel != _text.value.selection) {
// Selection-only change from an external setSelection (ex-line `:N` goto,
// find-in-files line jump on the already-active buffer) — content is
// unchanged, so move just the caret. The focused field scrolls it into
// view (T-407).
_text.removeListener(_onTextChanged);
_text.value = _text.value.copyWith(selection: sel);
_text.addListener(_onTextChanged);
}
setState(() {}); // tab/title refresh
}
@@ -274,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
}
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
if (intent is! InvokeCommandIntent) return;
if (intent is! InvokeCommandIntent) {
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
// The editor only owns editor.vim.* / mode commands; bubble anything else
// to the app-root Actions so it reaches its global handler (T-407).
Actions.maybeInvoke(context, intent);
return;
}
final id = intent.commandId;
if (!id.startsWith('editor.vim.')) {
// Mode change (vim.mode.*) or any other command.
@@ -297,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();
@@ -357,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';
+27 -5
View File
@@ -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),
),
],
);
}
}
+3 -3
View File
@@ -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(
+7 -7
View File
@@ -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,
+10 -5
View File
@@ -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,
),
+1 -1
View File
@@ -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'),
],
),
],
),
],
),
),
];
}
+3 -3
View File
@@ -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,
+2
View File
@@ -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),
],
),
+8 -3
View File
@@ -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),
+7 -2
View File
@@ -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)),
)
+1 -1
View File
@@ -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;
+8 -8
View File
@@ -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)),
),
],
),
+2 -2
View File
@@ -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;
+5 -5
View File
@@ -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(
+4 -4
View File
@@ -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)),
),
],
),
+14 -14
View File
@@ -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: [
+2
View File
@@ -1 +1,3 @@
export 'src/extension.dart';
export 'src/settings_category_view.dart';
export 'src/settings_modal.dart';
+32 -5
View File
@@ -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,
),
),
);
}
}
+2 -2
View File
@@ -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),
],
),
),
);
}
}
+62 -4
View File
@@ -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: 'JosefinSans',
options: [
SettingsOption(value: 'JosefinSans', label: 'Josefin Sans'),
SettingsOption(value: 'Inter', label: 'Inter'),
],
),
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';
+12 -10
View File
@@ -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),

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