diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 979bf7a7..17a3adb3 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -329,3 +329,34 @@ Acceptance: - Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream. Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', NULL, '2026-06-06 07:31:06', '2026-06-06 07:31:06', '2026-06-06 07:31:06', NULL, '1aa3d2ea3fd0501d699c5a26d87ac4eb', 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-250', 'status', 'backlog', 'ready', NULL, '2026-06-06 07:32:57', '2026-06-06 07:32:57', '2026-06-06 07:32:57', NULL, 'ab80a8820d40ede74f9eeed3eda250d6', 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-23', 'description', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired. + +**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav). + +**Acceptance:** +- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace. +- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight. +- Commands listed are everything registered via `CommandContribution` across all activated extensions. +- Fuzzy match against command title; recent / pinned commands float to the top. + +**Implementation hints:** +- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart). +- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy). +- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired. + +**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav). + +**Acceptance:** +- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace. +- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight. +- Commands listed are everything registered via `CommandContribution` across all activated extensions. +- Fuzzy match against command title; recent / pinned commands float to the top. + +**Implementation hints:** +- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart). +- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy). +- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open). + +DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '725ba044d75722c460c7869a2249c386', 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-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 6ff382a3..a2a28b94 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -731,3 +731,56 @@ Acceptance: - Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream. Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:31:06', NULL, 'e555fe45be44f3a22e30005370aa6ec0', 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-250', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated. + +Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback. +- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)). +- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause. +- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`. +- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status — so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead. + +So the status bar / badge value is not updated on change — matching the reported hypothesis. + +Fix direction: +- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it. +- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) — if the response reports failure, surface it rather than silently assuming success. + +Acceptance: +- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line. +- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess. +- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream. + +Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'ready', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:32:57', NULL, 'b40e943095a1e40ec8e868b392fd41c1', 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-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired. + +**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav). + +**Acceptance:** +- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace. +- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight. +- Commands listed are everything registered via `CommandContribution` across all activated extensions. +- Fuzzy match against command title; recent / pinned commands float to the top. + +**Implementation hints:** +- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart). +- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy). +- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open). + +DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'ready', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 07:40:43', NULL, 'b0194b050cfb6e5dec567933287c186f', 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-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired. + +**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav). + +**Acceptance:** +- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace. +- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight. +- Commands listed are everything registered via `CommandContribution` across all activated extensions. +- Fuzzy match against command title; recent / pinned commands float to the top. + +**Implementation hints:** +- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart). +- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy). +- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open). + +DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', 'done', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-06-06 07:40:43', NULL, '0a837620f95c93871c8dcbde152adffc', 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-251', 'task', 'T-4', 'Command palette: pinned commands + cross-session recency persistence (T-23 follow-up)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '3d69fb0be99289aa0d9f9bdd48f02aae', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d42003..cbd29dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed +- Command palette (⌘⇧P) now fuzzy-matches command titles (subsequence, not + just substring) and floats recently-used commands to the top. (T-23) - ⌘K now opens a **Settings** modal instead of a theme-only picker. Its first (currently only) section is Appearance — base themes, sorted, with a High contrast toggle for `-hc` siblings — matching the status-bar switcher. (T-238) diff --git a/lib/kernel/src/commands/palette.dart b/lib/kernel/src/commands/palette.dart index e6472f58..cafa6ffd 100644 --- a/lib/kernel/src/commands/palette.dart +++ b/lib/kernel/src/commands/palette.dart @@ -1,5 +1,6 @@ import 'package:clide/extension/src/contribution.dart'; import 'package:clide/kernel/src/commands/registry.dart'; +import 'package:clide/kernel/src/fuzzy.dart'; import 'package:flutter/foundation.dart'; class PaletteController extends ChangeNotifier { @@ -11,6 +12,12 @@ class PaletteController extends ChangeNotifier { String _filter = ''; int _selectedIndex = 0; + /// Recently-invoked command ids, most-recent-first. Floats recent commands + /// to the top of the list (empty query) and breaks fuzzy-score ties in their + /// favour. In-session only. Capped so it can't grow unbounded. + final List _recent = []; + static const int _recentCap = 20; + bool get isOpen => _open; String get filter => _filter; @@ -74,17 +81,51 @@ class PaletteController extends ChangeNotifier { await invoke(list[selectedIndex].command); } + /// The visible command list. Empty query → every command with recents + /// floated to the top (most-recent-first), the rest in registry order. A + /// non-empty query → a subsequence fuzzy match over each command's title + /// (or id), best-score first; ties break toward recents, then alphabetical. List filtered() { - if (_filter.isEmpty) return _registry.all.toList(); - final q = _filter.toLowerCase(); - return _registry.all.where((c) { - final haystack = (c.title ?? c.command).toLowerCase(); - return haystack.contains(q); - }).toList(); + final all = _registry.all.toList(); + final recentRank = {for (var i = 0; i < _recent.length; i++) _recent[i]: i}; + final order = {for (var i = 0; i < all.length; i++) all[i].command: i}; + int rank(String cmd) => recentRank[cmd] ?? (1 << 30); + + final q = _filter.trim().toLowerCase(); + if (q.isEmpty) { + all.sort((a, b) { + final c = rank(a.command).compareTo(rank(b.command)); + // Non-recents (equal rank) keep registry order. + return c != 0 ? c : order[a.command]!.compareTo(order[b.command]!); + }); + return all; + } + + final scored = <({CommandContribution cmd, int score})>[]; + for (final c in all) { + final s = fuzzyScore((c.title ?? c.command).toLowerCase(), q); + if (s != null) scored.add((cmd: c, score: s)); + } + scored.sort((a, b) { + final byScore = a.score.compareTo(b.score); + if (byScore != 0) return byScore; + final byRecent = rank(a.cmd.command).compareTo(rank(b.cmd.command)); + if (byRecent != 0) return byRecent; + return (a.cmd.title ?? a.cmd.command).toLowerCase().compareTo((b.cmd.title ?? b.cmd.command).toLowerCase()); + }); + return [for (final s in scored) s.cmd]; } Future invoke(String command) async { + _recordRecent(command); close(); await _registry.execute(command); } + + void _recordRecent(String command) { + _recent + ..remove(command) + ..insert(0, command); + if (_recent.length > _recentCap) _recent.removeRange(_recentCap, _recent.length); + } } diff --git a/lib/kernel/src/fuzzy.dart b/lib/kernel/src/fuzzy.dart new file mode 100644 index 00000000..3dcc9124 --- /dev/null +++ b/lib/kernel/src/fuzzy.dart @@ -0,0 +1,27 @@ +/// Subsequence fuzzy matching, shared by the command palette and the +/// quick-open file finder. Pure Dart (no Flutter) so it's reusable across +/// isolates and trivially unit-testable. +library; + +/// Returns null when [query]'s characters don't appear in order within +/// [text]; otherwise a score where **lower is better** — contiguous, early +/// matches score best; gaps between matched chars and a late first match add +/// penalty. Callers that want case-insensitive matching should lowercase both +/// arguments first. +int? fuzzyScore(String text, String query) { + if (query.isEmpty) return 0; + var ti = 0; + var qi = 0; + var score = 0; + int? last; + while (ti < text.length && qi < query.length) { + if (text.codeUnitAt(ti) == query.codeUnitAt(qi)) { + score += last == null ? ti : (ti - last - 1); + last = ti; + qi++; + } + ti++; + } + if (qi != query.length) return null; + return score; +} diff --git a/lib/kernel/src/quick_open.dart b/lib/kernel/src/quick_open.dart index 424fdc3c..7681fdcf 100644 --- a/lib/kernel/src/quick_open.dart +++ b/lib/kernel/src/quick_open.dart @@ -5,6 +5,7 @@ /// the palette's interaction model. library; +import 'package:clide/kernel/src/fuzzy.dart'; import 'package:flutter/foundation.dart'; class QuickOpenController extends ChangeNotifier { @@ -111,7 +112,7 @@ class QuickOpenController extends ChangeNotifier { final q = _filter.toLowerCase().trim(); final scored = <_Scored>[]; for (final p in _files) { - final s = _fuzzyScore(p.toLowerCase(), q); + final s = fuzzyScore(p.toLowerCase(), q); if (s != null) scored.add(_Scored(p, s)); } scored.sort((a, b) { @@ -128,25 +129,3 @@ class _Scored { final String path; final int score; } - -/// Subsequence fuzzy match. Returns null when [query]'s characters -/// don't appear in order within [text]; otherwise a score where lower -/// is better — contiguous, early matches score best (gaps and a late -/// start add penalty). -int? _fuzzyScore(String text, String query) { - if (query.isEmpty) return 0; - var ti = 0; - var qi = 0; - var score = 0; - int? last; - while (ti < text.length && qi < query.length) { - if (text.codeUnitAt(ti) == query.codeUnitAt(qi)) { - score += last == null ? ti : (ti - last - 1); - last = ti; - qi++; - } - ti++; - } - if (qi != query.length) return null; - return score; -} diff --git a/test/kernel/src/commands/palette_test.dart b/test/kernel/src/commands/palette_test.dart index 793196e1..4a6f50bf 100644 --- a/test/kernel/src/commands/palette_test.dart +++ b/test/kernel/src/commands/palette_test.dart @@ -57,5 +57,33 @@ void main() { expect(palette.isOpen, false); expect(palette.filter, ''); }); + + test('fuzzy subsequence match — non-contiguous chars (not substring)', () { + // "gc" / "gp" aren't substrings of the titles, but are subsequences. + palette.setFilter('gc'); + expect(palette.filtered().map((c) => c.command), contains('git.commit')); + palette.setFilter('gp'); + expect(palette.filtered().map((c) => c.command), contains('git.push')); + // Garbage that isn't a subsequence matches nothing. + palette.setFilter('zzz'); + expect(palette.filtered(), isEmpty); + }); + + test('recency floats invoked commands to the top on an empty filter', () async { + expect(palette.filtered().first.command, 'git.commit'); // registry order + await palette.invoke('theme.pick'); + expect(palette.filtered().first.command, 'theme.pick'); + await palette.invoke('git.push'); + expect(palette.filtered().map((c) => c.command).take(2).toList(), ['git.push', 'theme.pick']); + }); + + test('recency breaks fuzzy-score ties', () async { + // "git" matches both git.* titles with an equal score; invoking push + // floats it above commit among the equal matches. + await palette.invoke('git.push'); + palette.setFilter('git'); + final order = palette.filtered().map((c) => c.command).toList(); + expect(order.indexOf('git.push'), lessThan(order.indexOf('git.commit'))); + }); }); } diff --git a/test/kernel/src/fuzzy_test.dart b/test/kernel/src/fuzzy_test.dart new file mode 100644 index 00000000..ff9dad84 --- /dev/null +++ b/test/kernel/src/fuzzy_test.dart @@ -0,0 +1,35 @@ +import 'package:clide/kernel/src/fuzzy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('fuzzyScore', () { + test('empty query scores 0 (matches anything)', () { + expect(fuzzyScore('anything', ''), 0); + }); + + test('returns null when query is not a subsequence', () { + expect(fuzzyScore('git commit', 'xyz'), isNull); + expect(fuzzyScore('abc', 'abcd'), isNull); // query longer / extra char + }); + + test('matches non-contiguous subsequences', () { + expect(fuzzyScore('git commit', 'gc'), isNotNull); + expect(fuzzyScore('theme pick', 'tp'), isNotNull); + }); + + test('lower score is better: contiguous + early beats gappy + late', () { + final contiguousEarly = fuzzyScore('abcxxxx', 'abc')!; // match at 0,1,2 + final gappyLate = fuzzyScore('xxxxabc', 'abc')!; // starts at 4 + expect(contiguousEarly, lessThan(gappyLate)); + + final tight = fuzzyScore('ab', 'ab')!; // adjacent + final spread = fuzzyScore('axb', 'ab')!; // a gap between a and b + expect(tight, lessThan(spread)); + }); + + test('case sensitivity is the caller\'s responsibility', () { + expect(fuzzyScore('GIT', 'git'), isNull); // differing case → no match + expect(fuzzyScore('GIT'.toLowerCase(), 'git'), isNotNull); + }); + }); +}