diff --git a/docs/superpowers/plans/2026-09-30-quick-switcher-and-inline-ai.md b/docs/superpowers/plans/2026-09-30-quick-switcher-and-inline-ai.md new file mode 100644 index 0000000..16db99c --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-quick-switcher-and-inline-ai.md @@ -0,0 +1,47 @@ +# SP-1: Quick-switcher fuzzy overlay + Inline AI assist + +**Branch:** master +**Started:** 2026-09-30 IST +**Status:** in progress + +## Scope + +Replace the existing Cmd+P "Recent Files" menu with a fuzzy quick-switcher +overlay, and add Cmd+K inline AI assist (Rewrite / Shorten / Expand) on +selected text using the existing `AiProviders` infrastructure. + +## Design decisions (user-approved) + +| Question | Answer | +|---|---| +| Cmd+P behavior | Replace Recent Files menu with overlay | +| AI response mode | Typewriter stream into selection | +| AI action set | Rewrite + Shorten + Expand only (no custom prompts) | + +## Commit plan + +### SP-1A — Quick-switcher fuzzy overlay +- [ ] **C1**: `feat(quick-switcher): pure fuzzy matcher + ranker` — `src/quick-switcher/fuzzy-matcher.js` + tests +- [ ] **C2**: `feat(quick-switcher): IPC for listing workspace files` — main.js + preload.js wiring +- [ ] **C3**: `feat(quick-switcher): overlay UI + keyboard nav` — `src/quick-switcher/quick-switcher-overlay.js` + tests +- [ ] **C4**: `feat(quick-switcher): wire Cmd+P into renderer, drop old Recent Files menu` + +### SP-1B — Inline AI assist +- [ ] **C5**: `feat(ai-assist): pure prompt builder + result applier` — `src/ai-assist/inline-assist.js` + tests +- [ ] **C6**: `feat(ai-assist): IPC streaming wrapper around AiProviders` — main.js handler +- [ ] **C7**: `feat(ai-assist): floating popover + cancel/stop controls` — `src/renderer/inline-ai-popover.js` + tests +- [ ] **C8**: `feat(ai-assist): Cmd+K keymap + popover mount in renderer` + +## TDD discipline + +- All pure modules test: integration tests first, then implementation +- All renderer UI test: DOM contract tests using @testing-library/dom +- All IPC handlers: tests using injected fetch stub +- Lint + format green at every commit +- Grep forbidden markers before each commit claim + +## Out of scope (deferred) + +- Per-action configurable prompts +- Multi-selection batch assist +- AI inline assist without specific settings (built on AI Assistant plugin config) \ No newline at end of file diff --git a/src/quick-switcher/fuzzy-matcher.js b/src/quick-switcher/fuzzy-matcher.js new file mode 100644 index 0000000..a1fa6f9 --- /dev/null +++ b/src/quick-switcher/fuzzy-matcher.js @@ -0,0 +1,200 @@ +/** + * Quick-switcher fuzzy matcher. + * + * Pure module — no DOM, no IPC, no Node globals — so it can run in the + * renderer (for instant client-side filtering) or the main process (for + * precomputed indices). Tested under @jest-environment node. + * + * Scoring tiers: + * exact match → 1.00 (query === target, case-insensitive) + * stem match → 1.00 (query === target without first extension) + * prefix match → 0.80 + * subsequence match → 0.50 + bonuses, capped at 0.79 + * no match → 0.00 + * + * The stem tier exists because a user typing `idea` against a file named + * `idea.md` is signalling the same intent as typing the full name — they + * have the unique file in mind and aren't fuzzy-matching. Treating it as + * 1.0 (rather than 0.8 prefix) means recent/open-tabs boosts work as + * expected: 1.0 × 2.0 × 1.5 = 3.0 for an exact, open, recent file. + * + * Subsequence matching tries BOTH leftmost-greedy and rightmost-greedy + * alignments and keeps the higher-scoring one. Rightmost wins for queries + * that target a file's extension or suffix ("md" → "readme.md"); leftmost + * wins for queries that target a leading prefix ("re" → "readme.md"). + * + * Bonuses (each +0.05, capped): + * - word-boundary hit: previous char is one of / - _ space . + * OR position is at start-of-target (i=0) + * OR position is at end-of-target (i=length-1) + * - consecutive run: each pair of adjacent match positions in target + * + * Subsequence score is capped below the prefix tier (0.79) so the ranking + * is stable: prefix > subsequence even when the subsequence is a perfect + * substring match. + * + * @module fuzzy-matcher + */ + +const SEPARATORS = /[\/\-_. ]/; +const SUBSEQ_CAP = 0.79; +const BOUNDARY_BONUS = 0.05; +const CONSECUTIVE_BONUS = 0.1; + +/** + * Score `query` against `target` (typically a filename basename). + * + * @param {string} query + * @param {string} target + * @returns {number} score in [0, 1] + */ +function fuzzyScore(query, target) { + if (typeof query !== 'string' || typeof target !== 'string') return 0; + if (query.length === 0 || target.length === 0) return 0; + + const q = query.toLowerCase(); + const t = target.toLowerCase(); + + if (q === t) return 1.0; + + // Stem match: query equals target with the first extension stripped. + // 'idea' against 'idea.md' → stem 'idea' → 1.0 + // 'archive' against 'archive.tar.gz' → stem 'archive' → 1.0 + // '.gitignore' (no real extension) → stem '.gitignore', query must match whole name + const firstDot = t.indexOf('.'); + const stem = firstDot >= 1 ? t.slice(0, firstDot) : t; + if (q === stem) return 1.0; + + if (t.startsWith(q)) return 0.8; + + const leftMatch = greedyMatch(q, t, 'left'); + const rightMatch = greedyMatch(q, t, 'right'); + + const leftScore = leftMatch ? scoreMatch(leftMatch, t) : 0; + const rightScore = rightMatch ? scoreMatch(rightMatch, t) : 0; + + const best = Math.max(leftScore, rightScore); + return best > 0 ? Math.min(SUBSEQ_CAP, best) : 0; +} + +/** + * Greedy subsequence match. Returns the array of target indices where + * each query character was matched, or null if the query isn't a + * subsequence. + * + * 'left' → for each query char, take the first match at or after the + * previous match position. + * 'right' → for each query char (processed right-to-left), take the + * last match at or before the next-needed position. + */ +function greedyMatch(query, target, direction) { + const positions = []; + if (direction === 'left') { + let ti = 0; + for (let qi = 0; qi < query.length; qi++) { + let found = -1; + for (let j = ti; j < target.length; j++) { + if (target[j] === query[qi]) { + found = j; + break; + } + } + if (found === -1) return null; + positions.push(found); + ti = found + 1; + } + return positions; + } + // right + let ti = target.length; + for (let qi = query.length - 1; qi >= 0; qi--) { + let found = -1; + for (let j = ti - 1; j >= 0; j--) { + if (target[j] === query[qi]) { + found = j; + break; + } + } + if (found === -1) return null; + positions.unshift(found); + ti = found; + } + return positions; +} + +/** + * Score a match by counting boundary hits and consecutive pairs. + */ +function scoreMatch(positions, target) { + let boundaryHits = 0; + let consecutivePairs = 0; + const n = target.length; + + for (let i = 0; i < positions.length; i++) { + const p = positions[i]; + if (p === 0 || p === n - 1 || SEPARATORS.test(target[p - 1] || '')) { + boundaryHits++; + } + if (i > 0 && positions[i] === positions[i - 1] + 1) { + consecutivePairs++; + } + } + + return 0.5 + boundaryHits * BOUNDARY_BONUS + consecutivePairs * CONSECUTIVE_BONUS; +} + +/** + * Rank `items` against `query`, applying per-path boosts. Items with no + * match (and not boosted) are excluded. + * + * Boosts are multiplicative on the base score: + * - recent → ×2.0 + * - openTabs → ×1.5 + * + * An empty query bypasses scoring entirely and returns items purely by + * boost: recent first (×2.0), then open tabs (×1.5), then nothing. + * + * @param {string} query + * @param {Array<{path:string, name?:string}>} items + * @param {object} [boosts] + * @param {Set} [boosts.recent] + * @param {Set} [boosts.openTabs] + * @returns {Array<{item:object, score:number}>} sorted descending by score + */ +function rankResults(query, items, boosts = {}) { + if (!Array.isArray(items)) return []; + const { recent = new Set(), openTabs = new Set() } = boosts; + + const isEmptyQuery = typeof query !== 'string' || query.length === 0; + const out = []; + + for (const item of items) { + if (!item || typeof item.path !== 'string') continue; + + let score; + if (isEmptyQuery) { + if (recent.has(item.path)) score = 2.0; + else if (openTabs.has(item.path)) score = 1.5; + else continue; + } else { + const base = fuzzyScore(query, item.name || ''); + if (base === 0) continue; + score = base; + if (recent.has(item.path)) score *= 2.0; + if (openTabs.has(item.path)) score *= 1.5; + } + out.push({ item, score }); + } + + out.sort((a, b) => b.score - a.score); + return out; +} + +module.exports = { + fuzzyScore, + rankResults, + // exported for tests / advanced callers + greedyMatch, + scoreMatch, + SUBSEQ_CAP, +}; diff --git a/tests/quick-switcher-fuzzy-matcher.test.js b/tests/quick-switcher-fuzzy-matcher.test.js new file mode 100644 index 0000000..4f97e10 --- /dev/null +++ b/tests/quick-switcher-fuzzy-matcher.test.js @@ -0,0 +1,168 @@ +/** + * @jest-environment node + * + * Quick-switcher fuzzy matcher. + * + * Tiered scoring: + * - exact match → 1.00 + * - prefix match → 0.80 + * - subsequence match → 0.50 + bonuses (word-boundary, consecutive) + * - no match → 0.00 + * + * Empty query returns 0 for every target — the caller (rankResults) falls + * back to recency/open-tabs boosts alone to rank empty-query results. + * + * All scoring is case-insensitive. The original target casing is preserved + * for display; only the matching logic lowercases. + */ + +const { fuzzyScore, rankResults } = require('../src/quick-switcher/fuzzy-matcher'); + +describe('fuzzyScore', () => { + test('exact match returns 1.0', () => { + expect(fuzzyScore('readme.md', 'readme.md')).toBe(1.0); + }); + + test('exact match is case-insensitive', () => { + expect(fuzzyScore('README.md', 'readme.md')).toBe(1.0); + expect(fuzzyScore('readme.MD', 'README.md')).toBe(1.0); + }); + + test('prefix match returns ~0.8', () => { + const score = fuzzyScore('read', 'readme.md'); + expect(score).toBeGreaterThanOrEqual(0.8); + expect(score).toBeLessThan(0.9); + }); + + test('prefix match is case-insensitive', () => { + expect(fuzzyScore('READ', 'readme.md')).toBeCloseTo(0.8, 2); + }); + + test('subsequence match returns ~0.5 + bonuses', () => { + const score = fuzzyScore('rmd', 'readme.md'); + expect(score).toBeGreaterThanOrEqual(0.5); + expect(score).toBeLessThan(0.8); + }); + + test('subsequence match on word boundary scores higher than mid-word', () => { + const boundary = fuzzyScore('md', 'readme.md'); // 'md' is a suffix → still prefix-like + const mid = fuzzyScore('ea', 'readme.md'); // 'ea' is mid-word subsequence + expect(boundary).toBeGreaterThan(mid); + }); + + test('consecutive matches score higher than scattered matches', () => { + const consecutive = fuzzyScore('re', 'readme.md'); // contiguous + const scattered = fuzzyScore('rd', 'readme.md'); // not contiguous + expect(consecutive).toBeGreaterThan(scattered); + }); + + test('no match returns 0', () => { + expect(fuzzyScore('xyz', 'readme.md')).toBe(0); + expect(fuzzyScore('', '')).toBe(0); + }); + + test('empty query returns 0 (caller decides what to do)', () => { + expect(fuzzyScore('', 'readme.md')).toBe(0); + }); + + test('query longer than target returns 0', () => { + expect(fuzzyScore('readme.md.bak', 'readme.md')).toBe(0); + }); + + test('non-string inputs return 0', () => { + expect(fuzzyScore(null, 'readme.md')).toBe(0); + expect(fuzzyScore(undefined, 'readme.md')).toBe(0); + expect(fuzzyScore('rm', null)).toBe(0); + expect(fuzzyScore('rm', undefined)).toBe(0); + }); + + test('preserves display casing (does not mutate target)', () => { + const target = 'README.md'; + fuzzyScore('readme', target); + expect(target).toBe('README.md'); + }); +}); + +describe('rankResults', () => { + const items = [ + { path: '/notes/readme.md', name: 'readme.md' }, + { path: '/notes/rust/intro.md', name: 'intro.md' }, + { path: '/notes/idea.md', name: 'idea.md' }, + { path: '/archive/old-readme.md', name: 'old-readme.md' }, + { path: '/totally/unrelated.txt', name: 'unrelated.txt' }, + ]; + + test('sorts by score descending', () => { + const ranked = rankResults('readme', items); + expect(ranked.length).toBeGreaterThan(0); + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1].score).toBeGreaterThanOrEqual(ranked[i].score); + } + }); + + test('excludes zero-score items', () => { + const ranked = rankResults('xyz', items); + expect(ranked).toEqual([]); + }); + + test('applies recency boost (×2.0) to recent paths', () => { + const recent = new Set(['/archive/old-readme.md']); + const ranked = rankResults('readme', items, { recent }); + // old-readme is a prefix match → base 0.8; with ×2.0 boost = 1.6 + // readme.md is a prefix match → base 0.8; no boost + // So old-readme should rank first despite being older + expect(ranked[0].item.path).toBe('/archive/old-readme.md'); + }); + + test('applies open-tabs boost (×1.5)', () => { + const openTabs = new Set(['/notes/idea.md']); + const ranked = rankResults('idea', items, { openTabs }); + // idea.md is exact match → base 1.0; with ×1.5 boost = 1.5 + expect(ranked[0].item.path).toBe('/notes/idea.md'); + expect(ranked[0].score).toBeCloseTo(1.5, 2); + }); + + test('combines recency and open-tabs boosts (multiplicative)', () => { + const recent = new Set(['/notes/idea.md']); + const openTabs = new Set(['/notes/idea.md']); + const ranked = rankResults('idea', items, { recent, openTabs }); + // exact 1.0 × 2.0 × 1.5 = 3.0 + expect(ranked[0].score).toBeCloseTo(3.0, 2); + }); + + test('empty query falls back to boosts only — recent first, then open tabs', () => { + const recent = new Set(['/archive/old-readme.md']); + const openTabs = new Set(['/notes/rust/intro.md']); + const ranked = rankResults('', items, { recent, openTabs }); + // Recent items get base × 2.0; open tabs × 1.5; nothing else + expect(ranked[0].item.path).toBe('/archive/old-readme.md'); + expect(ranked[1].item.path).toBe('/notes/rust/intro.md'); + }); + + test('empty query with no boosts returns empty array', () => { + const ranked = rankResults('', items, {}); + expect(ranked).toEqual([]); + }); + + test('returns empty array for empty items', () => { + expect(rankResults('readme', [])).toEqual([]); + }); + + test('exact match beats prefix match beats subsequence match', () => { + const ranked = rankResults('idea', items); + // idea.md is exact match (1.0) + // old-readme.md is subsequence (matches 'i', 'd', 'e', 'a' across path chars) + expect(ranked[0].item.path).toBe('/notes/idea.md'); + }); + + test('handles identical names in different paths via path-aware boost', () => { + const dupItems = [ + { path: '/a/readme.md', name: 'readme.md' }, + { path: '/b/readme.md', name: 'readme.md' }, + ]; + const recent = new Set(['/b/readme.md']); + const ranked = rankResults('readme', dupItems, { recent }); + expect(ranked[0].item.path).toBe('/b/readme.md'); + expect(ranked[1].item.path).toBe('/a/readme.md'); + }); +});