10 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 980f60f798 release v2.1.0
test / dart doc (lib API) (push) Failing after 1m0s
test / unit + widget + golden + a11y (push) Failing after 39s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Drains the post-2.0 Unreleased backlog: keymap layer (T-117), keyboard
operability + panel-traversal arc (T-100, T-105, T-111, T-114), 95%
coverage gate (D-66), IPC argv-injection + size/count hardening
(T-104), high-contrast theme variants (D-69, T-118), PTY rewrite via
posix_spawn (T-96), CONTRIBUTING.md (T-109), and ~60 smaller items.
Closes T-121.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 10:19:37 +02:00
jpmschweitzerandClaude 0160f5e018 write CONTRIBUTING.md; dedupe Unreleased; add make verify (T-109)
test / unit + widget + golden + a11y (push) Failing after 33s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Three docs sweeps from the consultant batch:

* CONTRIBUTING.md lands as a human-addressed guide — clone, build,
  test, DQR, tickets, commit conventions. README links to it.
* CHANGELOG `[Unreleased]` consolidated to one subsection per kind
  in Keep a Changelog order (Added, Changed, Removed, Fixed,
  Security). Released sections left frozen.
* `make verify` is the no-tests sweep: analyze + format +
  decisions-validate + changelog-gate. Bridges the gap between
  `make analyze` alone and the full `make push-check`.

Item 3 (cut an interim release) deferred — that's the user's call
on timing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 10:06:03 +02:00
jpmschweitzerandClaude 31d40ad8ce harden IPC: reject -prefixed git refs, cap files.read / git.log (T-104)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
Three security fixes the consultant flagged:

* git.checkout, git.push now reject branch/remote arguments starting
  with `-` via a top-level validateGitRef helper. `git push` also
  gets a `--` option terminator; checkout can't use `--` without
  changing semantics (it would be parsed as a pathspec), so the
  validator is the only line of defence there.
* files.read caps responses at 10 MB so a single call can't OOM the
  UI on a multi-gigabyte log.
* git.log caps `count` at 1000; git.diff / git.stage cap paths at
  256. Excess is a userError rather than burning subprocess time.

The bigger typed-schema framework (item 1 in T-104) is split out as
T-120 since it needs design discussion alongside T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:53:20 +02:00
jpmschweitzerandClaude 683c90d0af cover vertical-axis + custom-slot branches in drag_resize_test
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Two more structural assertions to claw back the coverage gap the
keyboard-wiring lines opened up — the vertical-axis shortcut map and
the slot-name fallback label branch were both uncovered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:47:37 +02:00
jpmschweitzerandClaude 9249b1511d extract bumpedSlotSize for direct test coverage (T-111)
Pulled the slot-relative sign flip out of `_DragResizeHandleState._bump`
into a top-level `bumpedSlotSize` helper so the direction logic (the
bug-prone half) gets unit tests without piping through the keyboard
focus machinery. Adds a slot-label assertion for the context-panel
branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:45:47 +02:00
jpmschweitzerandClaude 5d1237501c panel splitters get keyboard parity and Semantics (T-111)
Drag handles for sidebar / context / editor split were pure
pointer-Listeners — no Tab focus, no arrow-key adjust, no Semantics.
Each now wraps in a FocusableActionDetector with arrow shortcuts (10
px fine / 50 px coarse for the column handles, 2% / 10% for the
editor split) and a slider Semantics node that announces the current
size. The CLI verb half is split out as T-119 and waits on T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:43:56 +02:00
jpmschweitzerandClaude f113cb5efd re-flow text_zoom_test.dart per dart format
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Pre-push hook split the cascaded `..increase()..increase()` chain
onto separate lines; landing the formatter's choice.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:05:37 +02:00
jpmschweitzerandClaude 044d1b2ff1 lift text-zoom into kernel, surface it in the palette (T-114)
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
Workspace text-zoom (Ctrl +/-/0) was local state on _RootShellState,
reachable only via the keymap intent path. Lifted to a kernel TextZoom
ChangeNotifier so the new `view.zoomIn/Out/Reset` palette commands
mutate the same number the keymap does — closing T-114's "discoverable
in the palette" item.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:04:43 +02:00
jpmschweitzerandClaude 6d4a642773 tokenise window-control colours + palette shadow
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Hard-coded Windows close-button red (#E81123), white close glyph, and
the palette's 0x40000000 drop shadow were the three colour sites the
UX consultant flagged as not adapting per theme. Now they're
`windowControl.closeHoverBackground` / `closeHoverForeground` /
`shadow.ambient`. Paper themes override the shadow to a softer ink so
it doesn't read as a CRT halo on cream.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:00:52 +02:00
jpmschweitzerandClaude 70c1bd3598 drop changelog gate's soft warning, keep 60-word hard cap
The 40-word warning never blocked a push, so eight bullets had drifted
over it. A warning the gate emits and the process ignores just
normalises drift, so it's gone — only the 60-word fail remains.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 08:59:42 +02:00
32 changed files with 1066 additions and 205 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ Entries should be short imperative phrases that describe user-facing impact —
### Be concise — this is the rule, not a suggestion
CHANGELOG entries must be **one or two short sentences**. Strict ceiling: **40 words per bullet**, hard cap at 60. If you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet** (enforced by `ci/changelog_gate.sh`). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
The CHANGELOG is read by humans scanning for what changed between two versions. It is **not** the place for the rationale, the probe results, the implementation detail, the behavior-change deep dive, or the "see also" cross-references. Those belong in:
@@ -1807,3 +1807,9 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:11:16', '2026-05-17 20:11:16', '2026-05-17 20:11:16', NULL, '6d51964a3ad7f1948f54a7c769336841', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:13:36', '2026-05-17 20:13:36', '2026-05-17 20:13:36', NULL, '0bad40fa7f165f828bb0c0757044491e', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'status', 'backlog', 'done', NULL, '2026-05-17 20:53:41', '2026-05-17 20:53:41', '2026-05-17 20:53:41', NULL, 'ff0126655805fed0ebe45f313c5ceff7', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:04:38', '2026-05-18 07:04:38', '2026-05-18 07:04:38', NULL, '8cf198f8c824fa8f46277b7e22e68f0c', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:43:45', '2026-05-18 07:43:45', '2026-05-18 07:43:45', NULL, '792efcf7c772de7cda7069ea3fc3ac0a', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 07:49:44', '2026-05-18 07:49:44', '2026-05-18 07:49:44', NULL, 'bf7f2746c252f8b8893f4fdc06d459b8', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:53:10', '2026-05-18 07:53:10', '2026-05-18 07:53:10', NULL, 'ce2b66658977edc3fddc78079d7852e5', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:02:06', '2026-05-18 08:02:06', '2026-05-18 08:02:06', NULL, 'ee75fea173ae492a6ad8a1d888a2142d', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '9dd61b76946909aa44edaf9d1c1741a0', 1) ON CONFLICT(hash) DO NOTHING;
+66
View File
@@ -1829,3 +1829,69 @@ Scope:
them — adding the files is enough.
Sources: D-22, D-69, consultants.md ("UX — Findings — [Minor]").', 'done', 'low', NULL, NULL, 'D-69', '2026-05-17 20:51:47', '2026-05-17 20:53:41', NULL, 'd163731890022856585e9f33432462f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual):
1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`.
2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged — at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme.
3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100).
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-18 07:04:38', NULL, '70c65844ee3e52bfcd6220b77ab8957a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'story', 'T-97', 'drag-resize keyboard parity + clide panel resize CLI', '`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers — no keyboard equivalent, no Semantics node. Per the user/Claude parity rule (D-6), panel sizing should have a CLI affordance. Neither exists.
**Fix:**
1. Add Semantics to drag-resize handles + keyboard activation (focus → arrow keys adjust width).
2. Add `clide panel resize <slot> <delta-or-absolute>` CLI verb.
Coordinate with T-99 (IPC implementation) if the CLI verb needs new dispatch wiring.
Source: consultants.md "UX — Findings — [Major] Drag-resize handles".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:14', '2026-05-18 07:43:45', NULL, '594d1cbcc8daa115246e2398d9ba9506', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'task', 'T-99', 'clide panel resize CLI verb (split from T-111)', 'Once T-99 lands an IPC dispatch path, register a ''panel.resize'' command that takes <slot> and <delta-or-absolute> args and calls LayoutArrangement.setSize / setEditorRatio. Keyboard parity already landed in T-111; this completes user/Claude parity per D-6.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-18 07:43:50', NULL, '29df32cde1a30168a27b763f5e6ce81d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-120', 'task', 'T-97', 'typed IPC command schema framework (split from T-104)', 'T-104 covered the spot-fixes (argv-injection rejection, size/count caps on specific commands). What remains is the framework piece: a typed schema per IPC command — branch/remote/path/etc. with regex/charset constraints — applied at DaemonDispatcher dispatch time rather than scattered through individual handlers.
This needs design before code:
- Where the schema lives (per-handler? a central registry?)
- How it composes with the IpcRequest envelope
- Whether it generates the existing handler boilerplate or wraps it
- Coordination with T-99''s IPC architecture decision (if we go socket-server, the schema becomes the wire contract)
Source: consultants.md "Security — Findings — [Major]" item 1.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 07:53:08', '2026-05-18 07:53:08', NULL, 'c2673e06e042bb418de9a99e8ab21c2b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'task', 'T-97', 'schema-validate IPC argument surface; reject -prefixed values', '`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`. `git diff`/`stage` use `--` separators (good), but `checkout(branch)` and `push(remote, branch)` (`git_commands.dart:240`) do not — argument injection (e.g. `git checkout --upload-pack=...`) is possible.
**Fix:**
1. Typed schema per IPC command (`branch`, `remote`, `path`, etc. with regex / charset constraints).
2. Size + count bounds on `files.read`, `git.log`, `git.diff`.
3. Either reject `-`-prefixed values for branch/remote/path args, or use `--` everywhere (including `checkout` and `push`).
**Acceptance:** unit tests cover the rejection path for each injection vector listed.
Source: consultants.md "Security — Findings — [Major]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:42', '2026-05-18 07:53:10', NULL, '688f888ba89cefe636ece09f32bea8ab', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'task', 'T-97', 'write CONTRIBUTING.md; cut interim release to drain Unreleased; merge duplicate changelog headings', 'Three docs items:
1. **No `CONTRIBUTING.md`.** Build/test story is scattered across `CLAUDE.md` (Claude-addressed), `README.md` (partly wrong — see T-101), and Makefile help. Write a human-addressed contributor guide: clone → `make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions.
2. **CHANGELOG has duplicate subsection headings in `[Unreleased]`** — three `### Changed`, two `### Fixed`, two `### Removed` in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release. Merge.
3. **~80-commit Unreleased backlog** undermines the format. Cut an interim release (`vX.Y.Z`) to drain it before continuing.
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
Source: consultants.md "Docs — Findings".', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:02:06', NULL, '01396c3a11fb562a859cdf76f933824c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'task', 'T-97', 'cut interim release to drain Unreleased (split from T-109)', 'Item 3 of T-109. With CONTRIBUTING.md landed and [Unreleased] consolidated, the remaining piece is cutting an actual release so the ~80-commit Unreleased backlog drains into a dated heading.
Per .claude/skills/git-commit/SKILL.md "Cutting a release":
1. Move every [Unreleased] entry under a new `## [X.Y.Z] — YYYY-MM-DD` heading.
2. Leave an empty [Unreleased] skeleton.
3. Bump pubspec.yaml version: (drop the -dev suffix for the tag).
4. Commit subject: `release vX.Y.Z`.
Version pick is the user''s call — likely 2.1.0 given the scope since 2.0.0 (D-66 95% coverage gate, T-117 keymap, contrast-gate split, IPC hardening, panel-keyboard parity, etc.).
Source: consultants.md "Docs — Findings" item 3.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '74aa71a8c832231c1debb66c767cf490', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'task', 'T-97', 'write CONTRIBUTING.md; cut interim release to drain Unreleased; merge duplicate changelog headings', 'Three docs items:
1. **No `CONTRIBUTING.md`.** Build/test story is scattered across `CLAUDE.md` (Claude-addressed), `README.md` (partly wrong — see T-101), and Makefile help. Write a human-addressed contributor guide: clone → `make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions.
2. **CHANGELOG has duplicate subsection headings in `[Unreleased]`** — three `### Changed`, two `### Fixed`, two `### Removed` in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release. Merge.
3. **~80-commit Unreleased backlog** undermines the format. Cut an interim release (`vX.Y.Z`) to drain it before continuing.
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
Source: consultants.md "Docs — Findings".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, '7c1f64a5aef6e3e92157691a2b192beb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+135 -123
View File
@@ -16,25 +16,19 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Added
### Changed
### Deprecated
### Removed
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution.
The separate daemon process was dissolved on 2026-04-23 but the entry
point and socket server class were never actually deleted. Gone now,
along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`,
`test/daemon/in_process_test`), stale i18n strings, and "start
`clide --daemon`" error messages.
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
C helper and its Dart wiring were never cleaned up. Removed from
toolchain resolution, `ToolCheck` gate, backend serialization,
testmode harness, CI scripts, Makefile, and sandbox entitlements.
D-5 amended to record the retirement.
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
of geometric shapes differs between macOS and Linux even with the
Ahem font, so a single set of CI goldens can't serve both platforms.
Replaced with platform-keyed goldens (`goldens/linux/`,
`goldens/macos/`), each only compared on its own OS.
### Fixed
### Security
## [2.1.0] — 2026-05-18
### Added
@@ -46,8 +40,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
which fails if total line coverage drops below `coverage_floor:` in
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
`## [Unreleased]` bullet over 60 words; warns at 40. Enforces the
Keep-a-Changelog conciseness rule in the git-commit skill.
`## [Unreleased]` bullet over 60 words. Enforces the Keep-a-Changelog
conciseness rule in the git-commit skill.
- Keymap layer (`KeymapService`) — typed Intents, YAML presets,
VS-Code-style when-clauses, layered preset → user file → settings
overlay. Default preset ships; vim/vscode/jetbrains unblocked
@@ -74,43 +68,42 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
17% → 96%. Crosses the 95% global target (T-91).
- Staged `dart doc` CI job — generates and uploads an HTML API
reference for the public `lib/` surface. The step wraps
`dart doc --validate-links` and grep-fails the build on any warning,
so broken doc refs and dangling links can't accumulate. Inert with
the rest of the workflow until Gitea Actions activates.
`dart doc --validate-links` and grep-fails the build on any warning.
Inert with the rest of the workflow until Gitea Actions activates.
- Mouse wheel scrolling in Claude pane — converts scroll events to
PgUp/PgDown so Claude Code (and other TUI apps) scroll their
history naturally.
PgUp/PgDown so TUI apps scroll their history naturally.
- Welcome screen Tips card — six common keybindings shown below the
START / RECENT row when the viewport is tall enough.
- `MultitabPane` widget + `MultitabController` for panes that host
N runtime tab instances of the same kind. Generic over a payload
type, supports pinned/non-closeable tabs (primary), drag-reorder,
close × on hover, and an optional `+` add button. Used by the
Claude pane to render primary + secondaries.
- `MultitabPane.keepAlive` mode — when set, all entry bodies stay
mounted via IndexedStack so switching tabs preserves their state
(PTY connections, scroll position, etc.).
### Fixed
- `TerminalView.onTapUp` now actually fires on primary tap — was
wired to a dead code path (T-93). Dead `onTapUp` surface on
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`,
which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove`
with the cursor at column 0 — common after `ESC[H\x1b[1J`
(home + erase-above) sequences that many TUIs emit on redraw.
- Terminal selections no longer vanish when resizing narrower —
reflow's tail-anchor handler left anchors detached past the
trimmed range. Common triggers: Ctrl+A then resize, drag past a
partially-filled line (T-92).
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
anchors due to concurrent list modification during iteration —
iteration now snapshots the list first (T-91).
type, supports pinned/non-closeable tabs, drag-reorder, close × on
hover, and an optional `+` add button.
- `MultitabPane.keepAlive` mode — entry bodies stay mounted via
IndexedStack so switching tabs preserves their state (PTY
connections, scroll position, etc.).
- `CONTRIBUTING.md` — human-addressed contributor guide covering
clone / build / test / DQR / tickets / commit conventions. The
`[Unreleased]` section is reorganised to one subsection per kind
per Keep a Changelog 1.1.0 (T-109).
- `make verify` — no-tests sweep (analyze + format + decisions +
changelog gate). For mid-edit checks; `make push-check` stays the
full pre-push pipeline.
### Changed
- Window-control close-button red, white close glyph, and palette
ambient shadow are now tokens (`windowControl.closeHover*`,
`shadow.ambient`) instead of hard-coded hex. Light themes get a
softer ink-tinted shadow (T-114).
- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows
up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114).
- Panel splitters (sidebar / context / editor-split) are tab-focusable;
arrow keys nudge by 10 px, Shift+arrow by 50 px (2% / 10% for the
editor split). Exposed as slider Semantics nodes so screen readers
announce the current size. CLI verb deferred to T-99 (T-111).
- Changelog gate is binary — dropped the soft 40-word warning, kept
the 60-word hard cap. Warnings that never blocked just normalised
drift.
- PTY spawning uses `posix_openpt` + `posix_spawn` instead of
`forkpty` — closes a ~5% deadlock window in the multithreaded Dart
VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at
@@ -119,102 +112,121 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset
loaders for fake-driven tests; production paths unchanged.
- Tidied test imports flagged by `unnecessary_import`.
- `README.md` rewritten to match current architecture; `docs/initial-plan.md`
bannered as historical; new `docs/architecture.md` describes today's
shape (T-101).
- `README.md` rewritten to match current architecture;
`docs/initial-plan.md` bannered as historical; new
`docs/architecture.md` describes today's shape (T-101).
- `SchedulerService._stopTicker` now awaits the in-flight isolate spawn
before killing — closes the same race shape we fixed in PTY (T-106).
- `make push-check-full` added — runs `push-check` plus integration +
smoke for pre-release checks. Integration tests skip the hanging
theme_picker case (T-116) until that's fixed (T-103).
theme_picker case until that's fixed (T-103, T-116).
- Governance bookkeeping: D-66 amended (floor at `coverage_floor:` in
`pubspec.yaml`); `licenses.yaml` reconciled with `pubspec.yaml`;
Q-1/Q-2/Q-3/Q-25 triaged; `.claude/skills/README.md` inventory
added; `--no-fatal-infos` dropped from `ci/test.sh` (T-113).
- Terminal panes now render bold attributes with a real bold weight —
bundled JetBrainsMono Bold + BoldItalic are registered with the
- Terminal panes render bold attributes with a real bold weight —
bundled JetBrainsMono Bold + BoldItalic registered with the
`JetBrainsMono` family at `weight: 700`. The painter's bold
suppression workaround (added when only Regular + Italic were wired
and Flutter's synthetic bold drifted advance widths) is gone.
suppression workaround is gone.
- Claude pane uses `MultitabPane` for primary + secondaries — drops
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
`clideInset*` for paddings, `clideGap*` for sibling distances,
`clideIcon*` / `clideControlHeight` for control sizes. Inline
pixel literals replaced where they were repeated.
### Changed
`clideIcon*` / `clideControlHeight` for control sizes.
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
### Fixed
- Closing a secondary Claude pane tab now kills its tmux session
on the clide socket, honouring D-41's "closing a secondary kills
that tmux session" lifecycle. Previously `pane.close` only killed
the ptyc-spawned tmux client and the server-side session leaked.
- Cold-start reap: every clide launch kills any leftover secondary
tmux sessions for the current repo before spawning new ones, so
D-41's "secondary numbering resets between runs" holds even after
an abrupt previous exit (kill -9, crash, force-quit).
- `claude.kill-all-sessions` command now actually kills the
server-side tmux sessions for the repo, not just the panes.
- Terminal cell grid no longer drifts on bold text — bold rendering
is suppressed at the painter level since synthetic bold (with no
Bold.ttf registered) shifts glyph advance widths.
- PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
instead of swallowing. `execve` failures write a diagnostic to
the slave before `_exit`. `NativePty.write` and `PtySession.write`
loop on short writes; both throw `PtyException` on hard errors.
- PTY teardown order fixed — kill child first so the master fd
returns EOF, await reader isolate exit, then close the fd.
Closing earlier could briefly target a reused fd.
- Reader isolate spawn errors in `NativePty` / `PtySession` are now
surfaced via the output stream instead of silently dropped.
`_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw;
`PtySession.spawn` closes the master fd if any post-receive step
fails.
- IPC server hardening: per-request 60s timeout (configurable),
broadcast/response write failures logged instead of swallowed,
client dropped on response-write failure, and the stale-socket
retry now probes for a live daemon before unlinking the socket
(refusing to start if one answers).
- `pane.spawn` and `editor.open` now map POSIX errno values to
actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM
→ `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST
→ distinct user-error/conflict, EMFILE/ENFILE → `tool_error`
with a "fd limit hit" hint. Previously every spawn/open failure
was an indistinguishable `tool_error`.
### Security
- Toolchain no longer resolves the dugite git binary against the open
workspace — a malicious repo could plant `native/dugite/bin/git`
and clide would run it on auto-fired `git.status`. Dugite now
resolves against the install dir + `CLIDE_DUGITE_DIR` env override
only (T-98).
- `files.read` and `files.ls` now reject symlinks whose targets live
outside the workspace — closes a path-safety bypass via in-repo
symlinks (T-102).
- `files.read` and `files.ls` now reject paths that resolve outside
the workspace root. Previously a relative path containing `..`
could read arbitrary files via path traversal.
### Changed
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
three transitive dependencies (xterm, quiver, zmodem).
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
- Claude pane spawns `claude` directly inside tmux with
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode
(input box pinned at the bottom).
- PTY read buffer increased from 4KB to 64KB.
- Terminal view 2px padding on all sides.
- Remove bold JetBrains Mono font registration to prevent glyph width
mismatch in terminal rendering.
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode.
- PTY read buffer increased from 4 KB to 64 KB.
- Terminal view 2 px padding on all sides.
### Removed
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56
dissolution. The separate daemon process was dissolved on 2026-04-23
but the entry point and socket server class were never deleted.
Gone now, along with orphaned tests, stale i18n strings, and
"start `clide --daemon`" error messages.
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
C helper and its Dart wiring were never cleaned up. Removed from
toolchain resolution, `ToolCheck` gate, backend serialization,
testmode harness, CI scripts, Makefile, and sandbox entitlements.
D-5 amended to record the retirement.
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
of geometric shapes differs between macOS and Linux even with the
Ahem font. Replaced with platform-keyed goldens (`goldens/linux/`,
`goldens/macos/`).
- Bold JetBrains Mono font registration that prevented glyph-width
mismatch in terminal rendering — superseded by the Bold/BoldItalic
re-registration above.
### Fixed
- `TerminalView.onTapUp` now actually fires on primary tap — was
wired to a dead code path (T-93). Dead `onTapUp` surface on
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
Real trigger: `Terminal.eraseDisplayAbove` with the cursor at
column 0 — common after `ESC[H\x1b[1J` (home + erase-above).
- Terminal selections no longer vanish when resizing narrower —
reflow's tail-anchor handler left anchors detached past the
trimmed range. Common triggers: Ctrl+A then resize, drag past a
partially-filled line (T-92).
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
anchors due to concurrent list modification during iteration —
iteration now snapshots the list first (T-91).
- Closing a secondary Claude pane tab now kills its tmux session on
the clide socket, honouring D-41's lifecycle. Previously
`pane.close` only killed the ptyc-spawned tmux client.
- Cold-start reap: every clide launch kills any leftover secondary
tmux sessions for the current repo before spawning new ones, so
D-41's "secondary numbering resets between runs" holds.
- `claude.kill-all-sessions` command now actually kills the
server-side tmux sessions for the repo, not just the panes.
- Terminal cell grid no longer drifts on bold text — bold rendering
is suppressed at the painter level since synthetic bold (with no
Bold.ttf registered) shifts glyph advance widths.
- PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
instead of swallowing. `execve` failures write a diagnostic to the
slave before `_exit`. `NativePty.write` and `PtySession.write`
loop on short writes; both throw `PtyException` on hard errors.
- PTY teardown order fixed — kill child first so the master fd
returns EOF, await reader isolate exit, then close the fd.
- Reader isolate spawn errors in `NativePty` / `PtySession` are now
surfaced via the output stream instead of silently dropped.
`_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw.
- IPC server hardening: per-request 60s timeout (configurable),
broadcast/response write failures logged instead of swallowed,
client dropped on response-write failure, and the stale-socket
retry now probes for a live daemon before unlinking the socket.
- `pane.spawn` and `editor.open` map POSIX errno values to actionable
IPC error kinds (ENOENT → `not_found`, EACCES/EPERM → `user_error`,
EISDIR/ENOTDIR/EEXIST → distinct kinds, EMFILE/ENFILE →
`tool_error` with an fd-limit hint).
### Security
- IPC: `git.checkout`, `git.push` reject branch/remote args starting
with `-` (closes the `--upload-pack=...` argv-injection vector).
`files.read` rejects files over 10 MB. `git.log` caps `count` at
1000; `git.diff` / `git.stage` cap paths at 256 (T-104).
- Toolchain no longer resolves the dugite git binary against the open
workspace — a malicious repo could plant `native/dugite/bin/git`
and clide would run it on auto-fired `git.status`. Dugite now
resolves against the install dir + `CLIDE_DUGITE_DIR` env override
only (T-98).
- `files.read` and `files.ls` reject symlinks whose targets live
outside the workspace — closes a path-safety bypass via in-repo
symlinks (T-102).
- `files.read` and `files.ls` reject paths that resolve outside the
workspace root. Previously a relative path containing `..` could
read arbitrary files via path traversal.
## [2.0.0] — 2026-05-03
+152
View File
@@ -0,0 +1,152 @@
# Contributing to clide
clide is an IDE for the Claude Code CLI, built as a single Flutter
package at the repo root. This guide is for people working on clide
itself.
For day-to-day model-driven work in the codebase, see
[`CLAUDE.md`](CLAUDE.md) — it documents the same guardrails from the
agent's point of view and is the place to look for "why is the code
shaped like this?"
## One-time setup
```
git clone git@github.com:postmeridiem/clide.git
cd clide
make hooks && flutter pub get
```
`make hooks` installs the repo's git hooks (pre-commit + post-merge).
Flutter must be on the stable channel and on `$PATH`.
If you haven't used [pql](https://github.com/postmeridiem/pql) before,
install it and run `pql init` once in the repo root. pql is a hard
dependency for the governance + ticket workflow described below.
## The five commands you'll actually use
```
make run # launch the desktop app
make verify # no-tests sweep — analyze + format + decisions + changelog gate
make test # fast suite — analyze + format + unit + widget + golden
make test-a11y # WCAG-AA contrast + keyboard traversal contracts
make push-check # the pre-push gate; what CI runs
make build-linux # release artefact for the host platform
```
`make verify` is the lightweight "are the gates green?" check for
mid-edit iteration. `make push-check` is the full pre-push pipeline
(verify + every test suite + coverage gate).
`make` with no target prints the full list. `make test-integration`
boots a real app process and is slow; reserve it for the rare change
that touches startup wiring.
The pre-push hook runs `make push-check` automatically. Don't bypass
it with `--no-verify` — fix the underlying issue and create a new
commit. Pre-push includes:
- `flutter analyze` (zero warnings)
- `dart format --set-exit-if-changed`
- the fast unit/widget/golden suites
- accessibility contract tests
- a coverage floor read from `coverage_floor:` in `pubspec.yaml`
(currently 95 %; ratchets up only — see
[D-66](governance/decisions/testing.md#d-66))
- `CHANGELOG.md` `[Unreleased]` bullets ≤ 60 words each
## Decisions, questions, rejected (DQR)
clide tracks architectural commitments as durable records under
[`governance/`](governance/):
- `decisions/<domain>.md` — confirmed decisions (`D-NNN`)
- `questions/<domain>.md` — open questions (`Q-NNN`)
- `rejected/<domain>.md` — rejected proposals (`R-NNN`)
When you make a non-trivial architectural choice, write it down:
```
pql decisions claim D <domain> "short title"
```
This reserves a fresh ID and tells you where to add the record. The
[governance/README.md](governance/README.md) explains the format and
the recommended domain list.
Pre-push validates that every `D-NNN` / `Q-NNN` / `R-NNN` link in
the docs and code resolves to an actual record (`pql decisions
validate`). Broken references fail the build.
## Tickets
All non-trivial work is tracked in `pql ticket`:
```
pql ticket list --status in_progress
pql ticket show T-NNN[,T-NNN…] # batch form on pql 1.4.33+
pql ticket new task "title" --parent T-NNN --priority medium
pql ticket status T-NNN in_progress
pql ticket status T-NNN done
```
A ticket exists for any change a reviewer might want to ask "why?"
about. Bug fixes, refactors, and consultant findings all become
tickets before the diff lands. Trivial typo fixes don't need one.
## Commit conventions
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).
- 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`.
- Co-author trailer:
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
any of the diff.
Never `--amend` a commit unless explicitly asked. Never force-push
to `main`. Never `git add -A` / `git add .` when staging — name
files explicitly so stray secrets or build artefacts don't sneak in.
## Cutting a release
A release is a single commit:
1. Move all `## [Unreleased]` entries under a new `## [X.Y.Z] —
YYYY-MM-DD` heading.
2. Leave an empty `## [Unreleased]` skeleton at the top.
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (no `-dev` suffix on
the release tag; add it back on the next development commit if
you like).
4. Commit subject: `release vX.Y.Z`.
`pubspec.yaml` is the single source of truth for the version — the
Makefile reads it for ldflag stamping and the app reads it for build
info.
## What goes where
- **Bug, feature, sweep:** file/claim a ticket, branch, code, test,
commit, push. Push triggers `make push-check`.
- **Architectural decision:** `pql decisions claim`, write the
record, then file the implementation ticket linked via
`decision_ref`.
- **Open question:** drop a `Q-NNN` under
`governance/questions/<domain>.md`. Triage later.
- **Rejected proposal:** drop an `R-NNN` under
`governance/rejected/<domain>.md`. Future-you (or a reviewer)
will be glad it's written down.
## Reporting issues
The public issue tracker lives at
<https://github.com/postmeridiem/clide/issues>. The Gitea mirror is
read-only.
+3
View File
@@ -79,6 +79,9 @@ analyze: ## flutter analyze.
format: ## dart format --set-exit-if-changed.
dart format --set-exit-if-changed .
.PHONY: verify
verify: analyze format decisions-validate changelog-gate ## No-tests sweep — analyze + format + decisions-validate + changelog-gate. For mid-edit "are the gates green?" checks; `push-check` is the full pre-push pipeline.
.PHONY: test
test: ## Fast: analyze + format + unit + widget + golden (<60s).
ci/test.sh
+1
View File
@@ -45,6 +45,7 @@ Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Te
## Documentation
- [`docs/architecture.md`](docs/architecture.md) — current architecture (read this first).
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to clone, build, test, file tickets, and write D-records.
- [`docs/initial-plan.md`](docs/initial-plan.md) — historical design doc; preserved as a snapshot of the 2026-04 plan, much of it now superseded.
- [`governance/decisions/`](governance/decisions/) — confirmed decisions (`D-NNN`), open questions (`Q-NNN`), rejected alternatives (`R-NNN`).
- [`CLAUDE.md`](CLAUDE.md) — Claude-addressed working notes (guardrails, repo layout).
+10 -22
View File
@@ -1,15 +1,14 @@
#!/usr/bin/env bash
# CHANGELOG concision gate — enforces the per-bullet word caps from the
# git-commit skill (40 soft, 60 hard) across the `## [Unreleased]`
# section. Released sections are frozen and skipped (don't penalize
# historical entries pre-dating the rule).
# CHANGELOG concision gate — enforces a single 60-word per-bullet hard
# cap on the `## [Unreleased]` section. Released sections are frozen
# and skipped (don't penalize historical entries pre-dating the rule).
#
# A "bullet" is a markdown list item beginning with `- `, including any
# indented continuation lines until the next bullet, blank line, or
# heading. Word count is whitespace-tokenized.
#
# Soft cap 40 → warn (non-zero exit only if HARD is also breached).
# Hard cap 60 → fail.
# A soft 40-word warning was tried earlier and dropped — warnings that
# never block a push just normalise drift, so the gate is now binary.
#
# Bypass: never. If the rule rejects something genuinely user-visible
# that needs more context, the context belongs in the commit body or a
@@ -18,7 +17,6 @@ set -euo pipefail
cd "$(dirname "$0")/.."
CHANGELOG=CHANGELOG.md
SOFT_CAP=40
HARD_CAP=60
if [[ ! -f "$CHANGELOG" ]]; then
@@ -26,24 +24,18 @@ if [[ ! -f "$CHANGELOG" ]]; then
exit 2
fi
awk -v soft="$SOFT_CAP" -v hard="$HARD_CAP" '
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0; warn = 0 }
awk -v hard="$HARD_CAP" '
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0 }
function check_bullet() {
if (bullet == "") return
n = split(bullet, _words, /[[:space:]]+/)
# split() counts a trailing empty token when the string starts/ends with
# whitespace; trim the leading "- " marker too.
# Trim the leading "- " marker, then tokenize on whitespace.
gsub(/^- +/, "", bullet)
n = split(bullet, _words, /[[:space:]]+/)
if (n > hard) {
printf "FAIL line %d: bullet is %d words (hard cap %d)\n", bullet_start, n, hard
printf "FAIL line %d: bullet is %d words (cap %d)\n", bullet_start, n, hard
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
fail++
} else if (n > soft) {
printf "WARN line %d: bullet is %d words (soft cap %d)\n", bullet_start, n, soft
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
warn++
}
bullet = ""
bullet_start = 0
@@ -76,10 +68,6 @@ awk -v soft="$SOFT_CAP" -v hard="$HARD_CAP" '
printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n"
exit 1
}
if (warn > 0) {
printf "==> changelog gate OK with %d warning(s) over %d words.\n", warn, soft
} else {
printf "==> changelog gate OK\n"
}
printf "==> changelog gate OK\n"
}
' "$CHANGELOG"
+76 -30
View File
@@ -55,24 +55,23 @@ class _RootShell extends StatefulWidget {
class _RootShellState extends State<_RootShell> {
late final FocusNode _keyFocus;
double _textScale = 1.0;
static const double _scaleStep = 0.05;
static const double _scaleMin = 0.6;
static const double _scaleMax = 2.0;
@override
void initState() {
super.initState();
_keyFocus = FocusNode()..requestFocus();
widget.services.textZoom.addListener(_onZoom);
}
@override
void dispose() {
widget.services.textZoom.removeListener(_onZoom);
_keyFocus.dispose();
super.dispose();
}
void _onZoom() => setState(() {});
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
@@ -86,24 +85,24 @@ class _RootShellState extends State<_RootShell> {
fontFamilyFallback: clideUiFamilyFallback,
),
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
child: Actions(
actions: <Type, Action<Intent>>{
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
onInvoke: (_) {
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
widget.services.textZoom.increase();
return null;
},
),
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
onInvoke: (_) {
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
widget.services.textZoom.decrease();
return null;
},
),
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
onInvoke: (_) {
setState(() => _textScale = 1.0);
widget.services.textZoom.reset();
return null;
},
),
@@ -346,7 +345,7 @@ class _WinBtn extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground;
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
@@ -354,7 +353,11 @@ class _WinBtn extends StatelessWidget {
height: hatHeight,
color: hovered ? hoverBg : null,
alignment: Alignment.center,
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.chromeForeground),
child: ClideIcon(
icon,
size: 14,
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
),
),
);
}
@@ -964,36 +967,79 @@ class _EditorDragHandle extends StatefulWidget {
class _EditorDragHandleState extends State<_EditorDragHandle> {
bool _hovered = false;
bool _focused = false;
double? _dragStartRatio;
double? _dragStartY;
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
// the pixel-based DragResizeHandle.
static const double _stepFine = 0.02;
static const double _stepCoarse = 0.10;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return MouseRegion(
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: (e) {
_dragStartRatio = widget.arrangement.editorRatio;
_dragStartY = e.position.dy;
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
return Semantics(
container: true,
slider: true,
label: 'Editor split',
value: '${(widget.arrangement.editorRatio * 100).round()}%',
onIncrease: () => _bump(_stepFine),
onDecrease: () => _bump(-_stepFine),
child: FocusableActionDetector(
onShowFocusHighlight: (v) => setState(() => _focused = v),
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
},
onPointerMove: (e) {
final startR = _dragStartRatio;
final startY = _dragStartY;
if (startR == null || startY == null || widget.totalHeight <= 0) return;
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
widget.arrangement.setEditorRatio(startR + deltaRatio);
actions: <Type, Action<Intent>>{
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
onInvoke: (intent) {
_bump(intent.delta);
return null;
},
),
},
onPointerUp: (_) {
_dragStartRatio = null;
_dragStartY = null;
},
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
child: MouseRegion(
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: (e) {
_dragStartRatio = widget.arrangement.editorRatio;
_dragStartY = e.position.dy;
},
onPointerMove: (e) {
final startR = _dragStartRatio;
final startY = _dragStartY;
if (startR == null || startY == null || widget.totalHeight <= 0) return;
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
widget.arrangement.setEditorRatio(startR + deltaRatio);
},
onPointerUp: (_) {
_dragStartRatio = null;
_dragStartY = null;
},
child: Container(height: 4, color: lineColor),
),
),
),
);
}
void _bump(double delta) {
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
}
}
class _EditorBumpIntent extends Intent {
const _EditorBumpIntent(this.delta);
final double delta;
}
class _ContextSlot extends StatelessWidget {
+57
View File
@@ -0,0 +1,57 @@
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
/// Surfaces view-level commands (currently the three text-zoom verbs)
/// in the command palette so they're discoverable. The keybindings
/// themselves are owned by the default keymap; commands here exist so
/// users browsing `Ctrl+Shift+P` see the same actions.
class ViewExtension extends ClideExtension {
ViewExtension({required this.textZoom});
final TextZoom textZoom;
@override
String get id => 'builtin.view';
@override
String get title => 'View';
@override
String get version => '0.1.0';
@override
Future<void> activate(ClideExtensionContext ctx) async {}
@override
List<ContributionPoint> get contributions => [
// Keybindings live in `assets/keymaps/default.yaml` against the
// text.scale* intents — registering a `defaultBinding` here too
// would shadow them. Palette discovery is the only goal.
CommandContribution(
id: 'view.zoomIn',
command: 'view.zoomIn',
title: 'View: Zoom In',
run: (_) async {
textZoom.increase();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
},
),
CommandContribution(
id: 'view.zoomOut',
command: 'view.zoomOut',
title: 'View: Zoom Out',
run: (_) async {
textZoom.decrease();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
},
),
CommandContribution(
id: 'view.zoomReset',
command: 'view.zoomReset',
title: 'View: Reset Zoom',
run: (_) async {
textZoom.reset();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
},
),
];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+1
View File
@@ -40,6 +40,7 @@ export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/scheduler.dart';
export 'src/text_zoom.dart';
export 'src/secrets.dart';
export 'src/tray.dart';
export 'src/panels/drag_resize.dart';
+6
View File
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/text_zoom.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
@@ -65,6 +66,7 @@ class KernelServices {
required this.toolchain,
required this.scheduler,
required this.keymap,
required this.textZoom,
});
final Logger log;
@@ -94,6 +96,7 @@ class KernelServices {
final Toolchain toolchain;
final SchedulerService scheduler;
final KeymapService keymap;
final TextZoom textZoom;
static Future<KernelServices> boot({
required Directory appDir,
@@ -151,6 +154,7 @@ class KernelServices {
final tc = toolchain ?? Toolchain();
final scheduler = SchedulerService(events);
scheduler.start();
final textZoom = TextZoom();
final project = ProjectManager(
log: log,
events: events,
@@ -225,6 +229,7 @@ class KernelServices {
toolchain: tc,
scheduler: scheduler,
keymap: keymap,
textZoom: textZoom,
);
}
@@ -247,6 +252,7 @@ class KernelServices {
extensions.dispose();
await scheduler.dispose();
keymap.dispose();
textZoom.dispose();
await log.dispose();
messages.dispose();
await events.dispose();
+104 -21
View File
@@ -1,11 +1,16 @@
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// A 4-px draggable splitter that adjusts the size of [slot] in the
/// given [arrangement]. Slot hosts wrap this around their edges to make
/// the three-column layout resizable.
/// A 4-px splitter that adjusts the size of [slot] in [arrangement].
/// Slot hosts wrap this around their edges to make the three-column
/// layout resizable.
///
/// Drag with the mouse, or tab to it and use the arrow keys (Shift =
/// coarse step). Exposes a `slider` Semantics node so screen readers
/// announce the current width.
class DragResizeHandle extends StatefulWidget {
const DragResizeHandle({
super.key,
@@ -21,6 +26,8 @@ class DragResizeHandle extends StatefulWidget {
final double thickness;
static const defaultThickness = 8.0;
static const double stepFine = 10.0;
static const double stepCoarse = 50.0;
@override
State<DragResizeHandle> createState() => _DragResizeHandleState();
@@ -28,32 +35,57 @@ class DragResizeHandle extends StatefulWidget {
class _DragResizeHandleState extends State<DragResizeHandle> {
bool _hovered = false;
bool _focused = false;
double? _dragStartSize;
Offset? _dragStartPointer;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.dividerColor;
return MouseRegion(
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onUp,
child: Container(
width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? widget.thickness : null,
color: tokens.chromeBackground,
child: Align(
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
final size = widget.arrangement.sizeOf(widget.slot);
return Semantics(
container: true,
slider: true,
label: _semanticLabel(),
value: size == null ? null : '${size.round()} pixels',
increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels',
decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels',
onIncrease: () => _bump(DragResizeHandle.stepFine),
onDecrease: () => _bump(-DragResizeHandle.stepFine),
child: FocusableActionDetector(
onShowFocusHighlight: (v) => setState(() => _focused = v),
shortcuts: _shortcuts(),
actions: <Type, Action<Intent>>{
_BumpIntent: CallbackAction<_BumpIntent>(
onInvoke: (intent) {
_bump(intent.delta);
return null;
},
),
},
child: MouseRegion(
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onUp,
child: Container(
width: widget.axis == Axis.horizontal ? 1 : null,
height: widget.axis == Axis.vertical ? 1 : null,
color: lineColor,
width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? widget.thickness : null,
color: tokens.chromeBackground,
child: Align(
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: widget.axis == Axis.horizontal ? (_focused ? 2 : 1) : null,
height: widget.axis == Axis.vertical ? (_focused ? 2 : 1) : null,
color: lineColor,
),
),
),
),
),
@@ -61,6 +93,38 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
);
}
String _semanticLabel() {
final axis = widget.axis == Axis.horizontal ? 'width' : 'height';
if (widget.slot == Slots.sidebar) return 'Sidebar $axis';
if (widget.slot == Slots.contextPanel) return 'Context panel $axis';
return '${widget.slot.value} $axis';
}
Map<ShortcutActivator, Intent> _shortcuts() {
final horiz = widget.axis == Axis.horizontal;
final fine = DragResizeHandle.stepFine;
final coarse = DragResizeHandle.stepCoarse;
return <ShortcutActivator, Intent>{
if (horiz) ...{
const SingleActivator(LogicalKeyboardKey.arrowLeft): _BumpIntent(-fine),
const SingleActivator(LogicalKeyboardKey.arrowRight): _BumpIntent(fine),
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): _BumpIntent(-coarse),
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): _BumpIntent(coarse),
} else ...{
const SingleActivator(LogicalKeyboardKey.arrowUp): _BumpIntent(-fine),
const SingleActivator(LogicalKeyboardKey.arrowDown): _BumpIntent(fine),
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _BumpIntent(-coarse),
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _BumpIntent(coarse),
},
};
}
void _bump(double rawDelta) {
final current = widget.arrangement.sizeOf(widget.slot);
if (current == null) return;
widget.arrangement.setSize(widget.slot, bumpedSlotSize(slot: widget.slot, current: current, rawDelta: rawDelta));
}
void _onDown(PointerDownEvent e) {
_dragStartSize = widget.arrangement.sizeOf(widget.slot);
_dragStartPointer = e.position;
@@ -80,3 +144,22 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
_dragStartPointer = null;
}
}
class _BumpIntent extends Intent {
const _BumpIntent(this.delta);
final double delta;
}
/// Apply a raw delta in the natural axis direction. Drag and arrow
/// keys both call this so the keyboard mirrors the drag: positive
/// delta = right/down. Context-panel sits on the right edge of the
/// app, so we flip the sign there — right-arrow should *shrink* it,
/// matching how dragging the left-edge handle rightward works.
double bumpedSlotSize({
required SlotId slot,
required double current,
required double rawDelta,
}) {
final delta = slot == Slots.contextPanel ? -rawDelta : rawDelta;
return current + delta;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
/// Workspace-wide text zoom factor.
///
/// Owned by the kernel rather than the root widget so command-palette
/// entries, the keymap layer, and any future menu/CLI surface can mutate
/// the same number. The root `MediaQuery` listens via [ChangeNotifier].
class TextZoom extends ChangeNotifier {
TextZoom();
static const double minScale = 0.6;
static const double maxScale = 2.0;
static const double stepScale = 0.05;
double _scale = 1.0;
double get scale => _scale;
void increase() => _setScale(_scale + stepScale);
void decrease() => _setScale(_scale - stepScale);
void reset() => _setScale(1.0);
void _setScale(double next) {
final clamped = next.clamp(minScale, maxScale);
if (clamped == _scale) return;
_scale = clamped;
notifyListeners();
}
}
+11
View File
@@ -96,6 +96,9 @@ class ThemeResolver {
modalOverlayBackground: surface[TokenKeys.modalOverlayBackground]!,
modalSurfaceBackground: surface[TokenKeys.modalSurfaceBackground]!,
modalSurfaceBorder: surface[TokenKeys.modalSurfaceBorder]!,
windowControlCloseHoverBackground: surface[TokenKeys.windowControlCloseHoverBackground]!,
windowControlCloseHoverForeground: surface[TokenKeys.windowControlCloseHoverForeground]!,
shadowAmbient: surface[TokenKeys.shadowAmbient]!,
dividerColor: surface[TokenKeys.dividerColor]!,
statusSuccess: surface[TokenKeys.statusSuccess]!,
statusWarning: surface[TokenKeys.statusWarning]!,
@@ -250,6 +253,14 @@ const Map<String, List<String>> _defaultSurfaceMap = {
TokenKeys.modalOverlayBackground: ['#C0000000'],
TokenKeys.modalSurfaceBackground: ['surface', 'semantic.mainchrome'],
TokenKeys.modalSurfaceBorder: ['accent', 'semantic.focus'],
// window controls — the Windows-style red close button is a hard
// platform convention, not a theme accent; the foreground stays
// white so the glyph remains visible across all themes.
TokenKeys.windowControlCloseHoverBackground: ['#FFE81123'],
TokenKeys.windowControlCloseHoverForeground: ['#FFFFFFFF'],
// shadow — translucent black works for dark themes; light themes can
// override this to a translucent ink (e.g. paper uses softer alpha).
TokenKeys.shadowAmbient: ['#40000000'],
// divider
TokenKeys.dividerColor: ['border', 'semantic.surface'],
// status
@@ -28,6 +28,9 @@ palette:
textMute: "#7A7A72"
accentSoft: "#218E2D10"
surface:
shadow.ambient: "#26000000"
syntax:
keyword: "#52215C"
type: "#154168"
+3
View File
@@ -25,6 +25,9 @@ palette:
textMute: "#A8A89E"
accentSoft: "#21C14B2A"
surface:
shadow.ambient: "#26000000"
syntax:
keyword: "#7B3F8C"
type: "#2A6FC1"
+26
View File
@@ -74,6 +74,11 @@ class SurfaceTokens {
required this.modalOverlayBackground,
required this.modalSurfaceBackground,
required this.modalSurfaceBorder,
// window controls
required this.windowControlCloseHoverBackground,
required this.windowControlCloseHoverForeground,
// shadow
required this.shadowAmbient,
// divider
required this.dividerColor,
// status
@@ -155,6 +160,17 @@ class SurfaceTokens {
final Color modalSurfaceBackground;
final Color modalSurfaceBorder;
/// Hover background on the Windows-style close button. Conventionally
/// the destructive-action red even on dark themes that don't otherwise
/// surface red as a CTA.
final Color windowControlCloseHoverBackground;
final Color windowControlCloseHoverForeground;
/// Ambient drop-shadow color for floating surfaces (palette,
/// dropdowns, modals). Typically a translucent black on dark themes
/// and a translucent ink on light themes.
final Color shadowAmbient;
final Color dividerColor;
final Color statusSuccess;
@@ -255,6 +271,13 @@ abstract class TokenKeys {
static const modalSurfaceBackground = 'modal.surfaceBackground';
static const modalSurfaceBorder = 'modal.surfaceBorder';
// window controls
static const windowControlCloseHoverBackground = 'windowControl.closeHoverBackground';
static const windowControlCloseHoverForeground = 'windowControl.closeHoverForeground';
// shadow
static const shadowAmbient = 'shadow.ambient';
// divider
static const dividerColor = 'divider.color';
@@ -325,6 +348,9 @@ abstract class TokenKeys {
modalOverlayBackground,
modalSurfaceBackground,
modalSurfaceBorder,
windowControlCloseHoverBackground,
windowControlCloseHoverForeground,
shadowAmbient,
dividerColor,
statusSuccess,
statusWarning,
+2
View File
@@ -20,6 +20,7 @@ import 'package:clide/builtin/problems/problems.dart';
import 'package:clide/builtin/settings_ui/settings_ui.dart';
import 'package:clide/builtin/terminal/terminal.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/builtin/view/view.dart';
import 'package:clide/builtin/tickets/tickets.dart';
import 'package:clide/builtin/todos/todos.dart';
import 'package:clide/builtin/welcome/welcome.dart';
@@ -145,6 +146,7 @@ Future<void> main() async {
..register(CanvasExtension())
..register(GraphExtension())
// UI extensions
..register(ViewExtension(textZoom: services.textZoom))
..register(SettingsUiExtension())
..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension())
+19
View File
@@ -13,6 +13,11 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
/// Cap on `files.read` response size. UI doesn't render multi-MB
/// blobs usefully and a single uncapped call can OOM. Range/stream
/// reads will land as a separate command (T-104 follow-up).
const int _filesReadMaxBytes = 10 * 1024 * 1024;
/// Daemon-side state for the `files` subsystem. Holds one
/// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet].
class FilesService {
@@ -84,6 +89,20 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
if (!file.existsSync()) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
}
// Cap response size so a single IPC call can't OOM the UI on a
// multi-gigabyte log file. Caller can paginate / stream via a
// future range-read variant when that ships.
final length = file.lengthSync();
if (length > _filesReadMaxBytes) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'file too large: $path ($length bytes; cap $_filesReadMaxBytes)',
),
);
}
final content = file.readAsStringSync();
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
});
+37 -1
View File
@@ -12,6 +12,16 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
/// Cap on `git.log --count` to keep a single query from spinning git
/// up on multi-million-commit repos. UI's history pane paginates;
/// callers asking for more should be using ranges instead.
const int _gitLogMaxCount = 1000;
/// Cap on `git.diff` and `git.stage` paths-list length so a single
/// IPC request can't queue up an unbounded fan-out of subprocess
/// arguments.
const int _gitPathsMaxCount = 256;
void registerGitCommands(
DaemonDispatcher d,
GitClient git,
@@ -30,6 +40,8 @@ void registerGitCommands(
try {
final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']);
final tooMany = _tooManyPaths(req.id, paths);
if (tooMany != null) return tooMany;
final diffs = await git.diff(staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: {
'staged': staged,
@@ -53,6 +65,8 @@ void registerGitCommands(
),
);
}
final tooMany = _tooManyPaths(req.id, paths);
if (tooMany != null) return tooMany;
try {
await git.stage(paths);
_emitChanged(events);
@@ -190,8 +204,18 @@ void registerGitCommands(
});
d.register('git.log', (req) async {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
if (count > _gitLogMaxCount) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'git.log count $count exceeds cap $_gitLogMaxCount',
),
);
}
try {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await git.log(count: count);
return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()],
@@ -264,6 +288,18 @@ List<String> _pathList(Object? raw) {
return const [];
}
IpcResponse? _tooManyPaths(String id, List<String> paths) {
if (paths.length <= _gitPathsMaxCount) return null;
return IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'paths length ${paths.length} exceeds cap $_gitPathsMaxCount',
),
);
}
void _emitChanged(DaemonEventSink events) {
events.emit(IpcEvent(
subsystem: 'git',
+10 -1
View File
@@ -9,7 +9,7 @@ import 'dart:io';
import '../../kernel/src/toolchain_paths.dart';
import 'diff.dart' show GitDiff, parseDiffOutput;
import 'operations.dart' show GitException, GitLogEntry;
import 'operations.dart' show GitException, GitLogEntry, validateGitRef;
import 'status.dart';
class GitClient {
@@ -191,8 +191,13 @@ class GitClient {
}
Future<String> push({String? remote, String? branch, bool setUpstream = false}) async {
if (remote != null) validateGitRef(remote, kind: 'remote');
if (branch != null) validateGitRef(branch, kind: 'branch');
final args = ['push'];
if (setUpstream) args.add('-u');
// `--` terminates option parsing — belt-and-suspenders alongside
// the ref validator above.
args.add('--');
if (remote != null) args.add(remote);
if (branch != null) args.add(branch);
final r = await _run(args);
@@ -201,6 +206,10 @@ class GitClient {
}
Future<void> checkout(String branch) async {
// `git checkout -- name` means pathspec, not branch — see the
// matching note in `operations.dart#gitCheckout`. validateGitRef
// is the only defence here.
validateGitRef(branch, kind: 'branch');
final r = await _run(['checkout', branch]);
if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String);
}
+28
View File
@@ -37,6 +37,21 @@ class GitException implements Exception {
String toString() => 'GitException: $message';
}
/// Validate a string about to be passed to git as a branch name,
/// remote name, or similar ref-shaped positional argument. Rejects
/// empty values and anything starting with `-`, which would otherwise
/// be parsed as an option flag by git (the classic
/// `--upload-pack=evil` argv-injection vector). Throws [GitException]
/// — callers convert it to the right IPC error kind.
void validateGitRef(String? value, {required String kind}) {
if (value == null || value.isEmpty) {
throw GitException('$kind is required');
}
if (value.startsWith('-')) {
throw GitException('$kind cannot start with "-" (looks like an option flag): $value');
}
}
class GitLogEntry {
const GitLogEntry({
required this.hash,
@@ -204,8 +219,14 @@ Future<String> gitPush(
String? branch,
bool setUpstream = false,
}) async {
if (remote != null) validateGitRef(remote, kind: 'remote');
if (branch != null) validateGitRef(branch, kind: 'branch');
final args = ['push'];
if (setUpstream) args.add('-u');
// `--` terminates option parsing — belt-and-suspenders alongside
// the ref validator above. Without it a future caller that bypasses
// the validator could still inject `--upload-pack=...`.
args.add('--');
if (remote != null) args.add(remote);
if (branch != null) args.add(branch);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
@@ -236,7 +257,14 @@ Future<List<({String name, bool current})>> gitBranches(Directory workDir) async
}
/// Checkout a branch.
///
/// `git checkout` overloads positionals: `-- <name>` means "restore
/// pathspec `<name>`", not "checkout branch `<name>`". So this can't
/// use `--` as an option terminator without changing semantics — the
/// [validateGitRef] guard against `-`-prefixed values is the only
/// argv-injection defence here. Use `gitSwitch` if/when we adopt it.
Future<void> gitCheckout(Directory workDir, String branch) async {
validateGitRef(branch, kind: 'branch');
final r = await Process.run(
gitBin,
['checkout', branch],
+6 -2
View File
@@ -108,7 +108,7 @@ class _WinButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground;
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
@@ -116,7 +116,11 @@ class _WinButton extends StatelessWidget {
height: hatHeight,
color: hovered ? hoverBg : null,
alignment: Alignment.center,
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.globalTextMuted),
child: ClideIcon(
icon,
size: 14,
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.globalTextMuted,
),
),
);
}
+3 -3
View File
@@ -126,11 +126,11 @@ class _ClidePaletteState extends State<ClidePalette> {
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: const [
boxShadow: [
BoxShadow(
color: Color(0x40000000),
color: tokens.shadowAmbient,
blurRadius: 12,
offset: Offset(0, 4),
offset: const Offset(0, 4),
),
],
),
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the
extension framework.
publish_to: none
version: 2.0.0
version: 2.1.0
repository: https://github.com/postmeridiem/clide
# Pre-push line-coverage floor. Ratchets up only — see D-66.
+16
View File
@@ -110,6 +110,22 @@ void main() {
expect(r.error!.message, contains('not found'));
});
test('files.read rejects a file over the size cap', () async {
// Cap is 10 MB; write 11 MB of zeros and confirm rejection rather
// than reading it into memory.
final big = File('${sandbox.path}/huge.bin');
final chunk = List<int>.filled(1024 * 1024, 0);
final sink = big.openWrite();
for (var i = 0; i < 11; i++) {
sink.add(chunk);
}
await sink.flush();
await sink.close();
final r = await call('files.read', const {'path': 'huge.bin'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('too large'));
});
test('files.ls with a path outside the root is rejected', () async {
final r = await call('files.ls', const {'path': '../escape'});
expect(r.ok, isFalse);
+41
View File
@@ -294,4 +294,45 @@ void main() {
final r = await call('git.stage', {'paths': 'new.txt'});
expect(r.ok, isTrue);
});
test('git.checkout rejects a -prefixed branch (argv-injection guard)', () async {
final r = await call('git.checkout', {'branch': '--upload-pack=evil'});
expect(r.ok, isFalse);
expect(r.error?.message, contains('branch'));
});
test('git.push rejects a -prefixed remote', () async {
final r = await call('git.push', {'remote': '--upload-pack=evil', 'branch': 'main'});
expect(r.ok, isFalse);
expect(r.error?.message, contains('remote'));
});
test('git.push rejects a -prefixed branch', () async {
final r = await call('git.push', {'remote': 'origin', 'branch': '--exec=evil'});
expect(r.ok, isFalse);
expect(r.error?.message, contains('branch'));
});
test('git.log over the count cap fails as userError', () async {
final r = await call('git.log', {'count': 100000});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(r.error?.message, contains('exceeds cap'));
});
test('git.diff with too many paths fails as userError', () async {
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
final r = await call('git.diff', {'paths': paths});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(r.error?.message, contains('exceeds cap'));
});
test('git.stage with too many paths fails as userError', () async {
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
final r = await call('git.stage', {'paths': paths});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.userError);
expect(r.error?.message, contains('exceeds cap'));
});
}
+42
View File
@@ -227,6 +227,48 @@ void main() {
} on GitException catch (_) {}
});
test('gitPush rejects a -prefixed remote (argv-injection guard)', () async {
try {
await gitPush(sandbox, remote: '--upload-pack=evil', branch: 'main');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('remote'));
}
});
test('gitPush rejects a -prefixed branch', () async {
try {
await gitPush(sandbox, remote: 'origin', branch: '--exec=evil');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('gitCheckout rejects a -prefixed branch', () async {
try {
await gitCheckout(sandbox, '--upload-pack=evil');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('gitCheckout rejects an empty branch', () async {
try {
await gitCheckout(sandbox, '');
fail('expected GitException');
} on GitException catch (e) {
expect(e.message, contains('branch'));
}
});
test('validateGitRef accepts plain refs', () {
expect(() => validateGitRef('main', kind: 'branch'), returnsNormally);
expect(() => validateGitRef('feature/foo', kind: 'branch'), returnsNormally);
expect(() => validateGitRef('origin', kind: 'remote'), returnsNormally);
});
test('gitPull against no remote throws GitException', () async {
try {
await gitPull(sandbox);
@@ -4,6 +4,7 @@ library;
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -83,6 +84,114 @@ void main() {
expect(arr.sizeOf(Slots.contextPanel), 150);
});
testWidgets('exposes a slider Semantics node with the current size', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, defaultSize: 240),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 40,
height: 200,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
expect(data.label, 'Sidebar width');
expect(data.value, '240 pixels');
final actions = data.getSemanticsData().actions;
expect(actions & SemanticsAction.increase.index, isNot(0));
expect(actions & SemanticsAction.decrease.index, isNot(0));
semHandle.dispose();
});
testWidgets('contextPanel slider Semantics label matches the slot', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, defaultSize: 320),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 40,
height: 200,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
expect(data.label, 'Context panel width');
expect(data.value, '320 pixels');
semHandle.dispose();
});
test('bumpedSlotSize keeps natural sign for left-anchored slots', () {
expect(bumpedSlotSize(slot: Slots.sidebar, current: 200, rawDelta: 10), 210);
expect(bumpedSlotSize(slot: Slots.sidebar, current: 200, rawDelta: -10), 190);
expect(bumpedSlotSize(slot: Slots.workspace, current: 500, rawDelta: 50), 550);
});
test('bumpedSlotSize flips sign for the right-anchored context panel', () {
expect(bumpedSlotSize(slot: Slots.contextPanel, current: 200, rawDelta: 10), 190);
expect(bumpedSlotSize(slot: Slots.contextPanel, current: 200, rawDelta: -10), 210);
});
testWidgets('vertical-axis handle uses arrow up/down shortcuts and "height" label', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.workspace, position: SlotPosition.center, defaultSize: 300),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 200,
height: 40,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.workspace,
axis: Axis.vertical,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
// Custom (non-sidebar, non-contextPanel) slots fall through to
// the slot.value-based label branch.
expect(data.label, 'workspace height');
semHandle.dispose();
});
testWidgets('hovered state flips the line colour without throwing', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
+62
View File
@@ -0,0 +1,62 @@
import 'package:clide/kernel/src/text_zoom.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TextZoom', () {
test('starts at 1.0', () {
expect(TextZoom().scale, 1.0);
});
test('increase adds one step, notifies listeners', () {
final z = TextZoom();
var calls = 0;
z.addListener(() => calls++);
z.increase();
expect(z.scale, closeTo(1.0 + TextZoom.stepScale, 1e-9));
expect(calls, 1);
});
test('decrease subtracts one step', () {
final z = TextZoom();
z.decrease();
expect(z.scale, closeTo(1.0 - TextZoom.stepScale, 1e-9));
});
test('reset jumps back to 1.0', () {
final z = TextZoom()
..increase()
..increase();
expect(z.scale, isNot(1.0));
z.reset();
expect(z.scale, 1.0);
});
test('clamps at minScale', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.decrease();
}
expect(z.scale, TextZoom.minScale);
});
test('clamps at maxScale', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.increase();
}
expect(z.scale, TextZoom.maxScale);
});
test('no-op increment does not notify', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.increase();
}
// Already at max — the next increase shouldn't fire.
var calls = 0;
z.addListener(() => calls++);
z.increase();
expect(calls, 0);
});
});
}