From df4bdc9d5833248a392a184444674d9f78f42742 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:27:36 +0530 Subject: [PATCH] opentui(v6): skill highlighting + one-edit autocorrect (anti-jank) --- ui-opentui/src/logic/skillMatch.ts | 161 ++++++++++++++ ui-opentui/src/test/lib/render.ts | 5 + ui-opentui/src/test/skillMatch.test.ts | 180 ++++++++++++++++ ui-opentui/src/test/skillSuggest.test.tsx | 252 ++++++++++++++++++++++ ui-opentui/src/view/composer.tsx | 131 ++++++++++- 5 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 ui-opentui/src/logic/skillMatch.ts create mode 100644 ui-opentui/src/test/skillMatch.test.ts create mode 100644 ui-opentui/src/test/skillSuggest.test.tsx diff --git a/ui-opentui/src/logic/skillMatch.ts b/ui-opentui/src/logic/skillMatch.ts new file mode 100644 index 000000000000..fd66d42776f0 --- /dev/null +++ b/ui-opentui/src/logic/skillMatch.ts @@ -0,0 +1,161 @@ +/** + * Slash-token matching for the composer (Epic 6) — pure tokenizer + matcher, + * no deps, fully table-testable. The composer uses this to: + * + * 1. HIGHLIGHT a `/name` token whose name exactly matches a valid + * command/skill name (native textarea highlight ranges), + * 2. SUGGEST an autocorrect when the message IS a bare `/name` token at the + * very start and the name is exactly one edit away (Damerau-Levenshtein / + * OSA distance 1) from exactly ONE valid name — surfaced through the + * existing completion dropdown, never auto-applied. + * + * Anti-jank rule (the whole point): a `/` in the middle of prose must NOT + * trigger completion or autocorrect. Mid-prose tokens get highlight-only when + * they exactly match a valid name; otherwise nothing happens. Path-looking + * tokens (`a/b`, `/usr/bin`, `./x`) are never tokens at all. + * + * The catalog of valid names is supplied by the caller (the composer LEARNS it + * from the slash-completion batches the gateway already sends — the completion + * flow is the source of truth; nothing is hardcoded here). + */ + +/** A standalone `/name` token found in the composer text. */ +export interface SlashToken { + /** The name WITHOUT the leading `/`. */ + name: string + /** Char offset of the leading `/` in the text. */ + start: number + /** Char offset one past the last name char. */ + end: number + /** Whether the token sits at the very start of the message (offset 0). */ + lead: boolean +} + +/** An autocorrect suggestion for the lead token (`/comit` → `commit`). */ +export interface SlashSuggestion { + /** The corrected name (no slash). */ + name: string + /** Char offset the accepted suggestion replaces from (just past the `/`). */ + from: number +} + +export interface SlashAnalysis { + /** Tokens whose name EXACTLY matches a valid name — highlight these. */ + highlights: SlashToken[] + /** The one-edit autocorrect for a bare lead token; null when none applies. */ + suggestion: SlashSuggestion | null +} + +/** Command/skill name charset: starts alphanumeric, then word chars / `.` / `-`. + * Notably EXCLUDES `/` — `/usr/bin` is a path, never a command token. */ +const NAME_RE = /^[A-Za-z0-9][\w.-]*$/ + +const isSpace = (ch: string | undefined): boolean => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' + +/** + * Extract every standalone `/name` token. Boundary rules: + * - the `/` must be at the start of the text or preceded by whitespace + * (`a/b` and `path/to` are not tokens), + * - the name runs to the next whitespace (or end) and must match NAME_RE + * (`/usr/bin` has a `/` in the body → not a token; bare `/` is nothing). + */ +export function slashTokens(text: string): SlashToken[] { + const tokens: SlashToken[] = [] + for (let i = 0; i < text.length; i++) { + if (text[i] !== '/') continue + if (i > 0 && !isSpace(text[i - 1])) continue + let j = i + 1 + while (j < text.length && !isSpace(text[j])) j++ + const name = text.slice(i + 1, j) + if (NAME_RE.test(name)) tokens.push({ end: j, lead: i === 0, name, start: i }) + i = j + } + return tokens +} + +/** + * Whether `a` and `b` are EXACTLY one edit apart under Damerau-Levenshtein + * (OSA): one substitution, one insertion/deletion, or one adjacent + * transposition. Equal strings are zero edits → false. + */ +export function isOneEdit(a: string, b: string): boolean { + if (a === b) return false + const la = a.length + const lb = b.length + if (Math.abs(la - lb) > 1) return false + if (la === lb) { + // one substitution, or one adjacent transposition + let i = 0 + while (i < la && a[i] === b[i]) i++ + if (i === la) return false // identical (handled above, defensive) + // try substitution: rest after i must match + if (a.slice(i + 1) === b.slice(i + 1)) return true + // try transposition of i,i+1 + return i + 1 < la && a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2) + } + // one insertion/deletion: align the longer against the shorter + const [short, long] = la < lb ? [a, b] : [b, a] + let i = 0 + while (i < short.length && short[i] === long[i]) i++ + return short.slice(i) === long.slice(i + 1) +} + +/** + * Analyze the composer text against the valid-name catalog. + * Matching is CASE-SENSITIVE per the catalog (commands are stored lowercase; + * `/Help` is not exact — though it IS one edit from `help`, so it suggests). + * + * Suggestion rules (anti-jank): + * - only for the LEAD token, and only while the message is EXACTLY the bare + * token (`/comit` — not `/comit args`, never mid-prose), + * - the token must not already be exact, + * - exactly ONE catalog name within one edit; ambiguity → nothing. + */ +export function analyzeSlash(text: string, names: ReadonlySet): SlashAnalysis { + const tokens = slashTokens(text) + const highlights = tokens.filter(t => names.has(t.name)) + let suggestion: SlashSuggestion | null = null + const lead = tokens[0] + if (lead && lead.lead && text === `/${lead.name}` && !names.has(lead.name)) { + let candidate: string | undefined + let count = 0 + for (const n of names) { + if (isOneEdit(lead.name, n)) { + candidate = n + if (++count > 1) break + } + } + if (count === 1 && candidate !== undefined) suggestion = { from: 1, name: candidate } + } + return { highlights, suggestion } +} + +/** + * Names learnable from a slash-completion batch. Only when the composer text is + * a bare lead token (`/…` with no space) are the candidates command/skill NAMES + * — after a space the gateway completes ARGS (`/details thinking`), which must + * not pollute the catalog. Item text arrives as `name `, `name`, or `/name` + * (the gateway's extras carry the slash); all normalize to the bare name. + */ +export function learnableNames(text: string, items: ReadonlyArray<{ text: string }>): string[] { + if (!/^\/\S*$/.test(text)) return [] + const out: string[] = [] + for (const item of items) { + let name = item.text.trim() + if (name.startsWith('/')) name = name.slice(1) + if (NAME_RE.test(name)) out.push(name) + } + return out +} + +/** + * Convert a JS string offset into the native highlight char offset: the native + * char-range counter skips newlines (mirror of ExtmarksController. + * offsetExcludingNewlines in @opentui/core for plain-width text). + */ +export function nativeCharOffset(text: string, offset: number): number { + let newlines = 0 + const max = Math.min(offset, text.length) + for (let i = 0; i < max; i++) if (text[i] === '\n') newlines++ + return offset - newlines +} diff --git a/ui-opentui/src/test/lib/render.ts b/ui-opentui/src/test/lib/render.ts index c74a87888e8e..de7c4e442c93 100644 --- a/ui-opentui/src/test/lib/render.ts +++ b/ui-opentui/src/test/lib/render.ts @@ -17,6 +17,7 @@ * real app; here we provide a test keymap built from the test renderer (read via * `useRenderer()` inside the tree) so headless mounts of those views work. */ +import type { CapturedFrame } from '@opentui/core' import type { TestRendererSetup } from '@opentui/core/testing' import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui' import { KeymapProvider } from '@opentui/keymap/solid' @@ -47,6 +48,9 @@ function withKeymap(node: () => JSX.Element): () => JSX.Element { export interface RenderProbe { readonly frame: () => string + /** Styled spans of the current frame (per-span fg/bg/attributes) — for + * asserting COLOR, e.g. the composer's slash-token highlight (Epic 6). */ + readonly spans: () => CapturedFrame readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise readonly resize: (width: number, height: number) => void /** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */ @@ -84,6 +88,7 @@ export async function renderProbe( return { frame: () => setup.captureCharFrame(), + spans: () => setup.captureSpans(), waitForFrame: predicate => setup.waitForFrame(predicate), resize: (width, height) => setup.resize(width, height), click: async (x, y) => { diff --git a/ui-opentui/src/test/skillMatch.test.ts b/ui-opentui/src/test/skillMatch.test.ts new file mode 100644 index 000000000000..8be28b2f9373 --- /dev/null +++ b/ui-opentui/src/test/skillMatch.test.ts @@ -0,0 +1,180 @@ +/** + * skillMatch table tests (Epic 6) — the pure tokenizer/matcher behind the + * composer's skill highlighting + one-edit autocorrect. Pins the anti-jank + * boundary rules: start-of-message detection, standalone-token extraction + * (paths are never tokens), exact-match vs one-edit-away (Damerau-Levenshtein / + * OSA distance 1), and mid-prose = highlight-only / suggestion-never. + */ +import { describe, expect, test } from 'vitest' + +import { + analyzeSlash, + isOneEdit, + learnableNames, + nativeCharOffset, + slashTokens, + type SlashToken +} from '../logic/skillMatch.ts' + +const NAMES = new Set(['commit', 'help', 'model', 'copy', 'skills']) + +const tok = (name: string, start: number, over: Partial = {}): SlashToken => ({ + end: start + 1 + name.length, + lead: start === 0, + name, + start, + ...over +}) + +describe('slashTokens — boundary rules', () => { + test.each<[string, string, SlashToken[]]>([ + ['lead token at message start', '/commit', [tok('commit', 0)]], + ['lead token with args', '/commit -m foo', [tok('commit', 0)]], + ['mid-prose token after a space', 'use /commit here', [tok('commit', 4)]], + ['token after a newline', 'hello\n/help', [tok('help', 6)]], + ['multiple tokens', '/help and /model', [tok('help', 0), tok('model', 10)]], + ['a/b path is NOT a token', 'see the a/b path', []], + ['slash glued to a word is NOT a token', 'foo/bar', []], + ['absolute path is NOT a token (slash in body)', '/usr/bin', []], + ['relative path is NOT a token', './run it', []], + ['bare slash is NOT a token', '/', []], + ['bare slash mid-prose is NOT a token', 'say / something', []], + ['empty text', '', []], + ['name charset: dots and dashes ok', '/skill-name.v2', [tok('skill-name.v2', 0)]], + ['name must start alphanumeric', '/-flag', []] + ])('%s', (_name, text, expected) => { + expect(slashTokens(text)).toEqual(expected) + }) +}) + +describe('isOneEdit — Damerau-Levenshtein (OSA) distance 1', () => { + test.each<[string, string, string, boolean]>([ + ['substitution', 'comjit', 'commit', true], + ['adjacent transposition', 'hlep', 'help', true], + ['deletion (typed an extra char)', 'helpp', 'help', true], + ['insertion (missed a char)', 'comit', 'commit', true], + ['identical = zero edits, not one', 'help', 'help', false], + ['two substitutions', 'hxlx', 'help', false], + ['two edits via lengths', 'he', 'help', false], + ['non-adjacent swap is two edits', 'pleh', 'help', false], + ['case-sensitive: one case flip is one edit', 'Help', 'help', true], + ['case-sensitive: full upcase is far', 'HELP', 'help', false], + ['empty vs one char', '', 'a', true], + ['empty vs two chars', '', 'ab', false] + ])('%s: %s ↔ %s → %s', (_name, a, b, expected) => { + expect(isOneEdit(a, b)).toBe(expected) + expect(isOneEdit(b, a)).toBe(expected) // symmetric + }) +}) + +describe('analyzeSlash — highlight + suggestion decision table', () => { + test('/commit at start = exact match → highlight, no suggestion', () => { + const a = analyzeSlash('/commit', NAMES) + expect(a.highlights).toEqual([tok('commit', 0)]) + expect(a.suggestion).toBeNull() + }) + + test('/comit at start = one-edit → suggestion `commit`, no highlight', () => { + const a = analyzeSlash('/comit', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toEqual({ from: 1, name: 'commit' }) + }) + + test('a/b path = nothing', () => { + const a = analyzeSlash('see the a/b path', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toBeNull() + }) + + test('mid-prose exact token = highlight ONLY (never a suggestion)', () => { + const a = analyzeSlash('use /commit here', NAMES) + expect(a.highlights).toEqual([tok('commit', 4)]) + expect(a.suggestion).toBeNull() + }) + + test('mid-prose near-miss token = nothing (anti-jank)', () => { + const a = analyzeSlash('use /comit here', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toBeNull() + }) + + test('/xyzzy = nothing (no exact, nothing within one edit)', () => { + const a = analyzeSlash('/xyzzy', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toBeNull() + }) + + test('case sensitivity per catalog: /Commit is not exact but IS one edit → suggests', () => { + const a = analyzeSlash('/Commit', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toEqual({ from: 1, name: 'commit' }) + }) + + test('case sensitivity per catalog: /COMMIT = nothing', () => { + const a = analyzeSlash('/COMMIT', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toBeNull() + }) + + test('suggestion only while the message IS the bare token: `/comit foo` → nothing', () => { + const a = analyzeSlash('/comit foo', NAMES) + expect(a.highlights).toEqual([]) + expect(a.suggestion).toBeNull() + }) + + test('exact lead token keeps its highlight with args after it', () => { + const a = analyzeSlash('/commit -m "x"', NAMES) + expect(a.highlights).toEqual([tok('commit', 0)]) + expect(a.suggestion).toBeNull() + }) + + test('ambiguity (two names within one edit) → no suggestion', () => { + const names = new Set(['help', 'helm']) + const a = analyzeSlash('/hela', names) + expect(a.suggestion).toBeNull() + }) + + test('a token after a newline is mid-prose: highlight-only', () => { + const a = analyzeSlash('note\n/help', NAMES) + expect(a.highlights).toEqual([tok('help', 5)]) + expect(a.suggestion).toBeNull() + }) + + test('empty catalog → nothing ever', () => { + expect(analyzeSlash('/help', new Set())).toEqual({ highlights: [], suggestion: null }) + }) +}) + +describe('learnableNames — catalog learning gate', () => { + test('bare lead token: names learned, slash/space variants normalized', () => { + expect( + learnableNames('/', [{ text: '/clear' }, { text: 'help ' }, { text: 'model' }, { text: 'two words' }]) + ).toEqual(['clear', 'help', 'model']) + }) + + test('arg completions (text has a space) are NOT learned', () => { + expect(learnableNames('/details ', [{ text: 'thinking' }])).toEqual([]) + expect(learnableNames('/details th', [{ text: 'thinking' }])).toEqual([]) + }) + + test('mid-prose / path menus are NOT learned', () => { + expect(learnableNames('use /', [{ text: 'clear' }])).toEqual([]) + expect(learnableNames('src/ma', [{ text: 'src/main.py' }])).toEqual([]) + }) + + test('path-shaped candidates are filtered even on a slash text', () => { + expect(learnableNames('/c', [{ text: 'src/main.py' }, { text: '' }])).toEqual([]) + }) +}) + +describe('nativeCharOffset — newline exclusion for native highlight ranges', () => { + test.each<[string, string, number, number]>([ + ['no newlines = identity', '/help', 3, 3], + ['one newline before', 'ab\ncd', 3, 2], + ['two newlines before', 'a\nb\nc', 4, 2], + ['offset before any newline', 'ab\ncd', 1, 1], + ['offset past end clamps newline scan', 'a\nb', 5, 4] + ])('%s', (_name, text, offset, expected) => { + expect(nativeCharOffset(text, offset)).toBe(expected) + }) +}) diff --git a/ui-opentui/src/test/skillSuggest.test.tsx b/ui-opentui/src/test/skillSuggest.test.tsx new file mode 100644 index 000000000000..3f0fcfaff315 --- /dev/null +++ b/ui-opentui/src/test/skillSuggest.test.tsx @@ -0,0 +1,252 @@ +/** + * Skill highlighting + one-edit autocorrect — composer-level tests (Epic 6). + * Headless frames through the real App + Composer with the entry-parity onType + * (planCompletion → fake gateway catalog → store.setCompletions), mirroring + * slashMenu.test.tsx: + * + * - `/comit` at start → the gateway prefix menu is empty, so the one-edit + * suggestion rides the SAME dropdown ("did you mean"); Enter accepts it and + * splices `/commit ` (no submit) — never auto-applied. + * - Esc dismisses the suggestion for that exact text (it must not re-open). + * - anti-jank: mid-prose typing (`use /comit here`, `see the a/b path`) never + * opens a menu and never autocorrects (pins the existing behavior too). + * - `/help` exact at start → the token is painted with the theme accent + * (native editBuffer highlight), asserted via styled spans. + * + * The learned-names catalog is module-level in the composer (it survives + * remounts), so each test resets it. + */ +import { RGBA } from '@opentui/core' +import { beforeEach, describe, expect, test } from 'vitest' + +import { createPromptHistory } from '../logic/history.ts' +import { planCompletion } from '../logic/slash.ts' +import { createSessionStore, type CompletionItem, type SessionStore } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { resetLearnedNames } from '../view/composer.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +/** Fake gateway catalog (what `complete.slash` returns for a `/` prefix). */ +const CATALOG: CompletionItem[] = [ + { display: '/clear', meta: 'clear the transcript', text: '/clear' }, + { display: '/commit', meta: 'commit changes', text: '/commit' }, + { display: '/copy', meta: 'copy the last response', text: '/copy' }, + { display: '/help', meta: 'list commands', text: '/help' }, + { display: '/model', meta: 'switch model', text: '/model' } +] + +interface Harness { + probe: RenderProbe + store: SessionStore + submitted: string[] + typed: string[] +} + +/** Mount the real App with entry-parity onType (planCompletion → fake catalog). */ +async function mountComposer(): Promise { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const submitted: string[] = [] + const typed: string[] = [] + const history = createPromptHistory({ initial: [] }) + const onType = (text: string) => { + typed.push(text) + const plan = planCompletion(text) + if (!plan || plan.method !== 'complete.slash') { + store.clearCompletions() + return + } + const q = String(plan.params.text).toLowerCase() + const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q) + if (items.length) store.setCompletions(items, plan.from) + else store.clearCompletions() + } + const probe = await renderProbe( + () => ( + store.state.theme}> + submitted.push(t)} onType={onType} history={history} /> + + ), + { height: 24, kittyKeyboard: true, width: 70 } + ) + return { probe, store, submitted, typed } +} + +beforeEach(() => resetLearnedNames()) + +describe('one-edit autocorrect rides the completion dropdown', () => { + test('`/comit` at start → the suggestion row appears; Enter accepts → `/commit `', async () => { + const h = await mountComposer() + try { + // type in two bursts (the mock coalesces a single typeText into one + // content change): the `/c` prefix batch teaches the catalog, then the + // menu closes (`comit` matches nothing) and the one-edit suggestion shows. + await h.probe.keys.typeText('/c') + await h.probe.settle() + await h.probe.keys.typeText('omit') + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => f.includes('did you mean')) + expect(frame).toContain('/commit') + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.typed.at(-1)).toBe('/commit ') // spliced from just past the `/` + expect(h.submitted).toEqual([]) // accepted, never submitted/auto-applied + expect(h.probe.frame()).not.toContain('did you mean') + } finally { + h.probe.destroy() + } + }) + + test('Tab also accepts the suggestion row', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/c') + await h.probe.settle() + await h.probe.keys.typeText('omit') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('did you mean')) + h.probe.keys.pressTab() + await h.probe.settle() + expect(h.typed.at(-1)).toBe('/commit ') + expect(h.submitted).toEqual([]) + } finally { + h.probe.destroy() + } + }) + + test('Esc dismisses the suggestion and it stays dismissed for the same text', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/c') + await h.probe.settle() + await h.probe.keys.typeText('omit') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('did you mean')) + h.probe.keys.pressEscape() + const frame = await h.probe.waitForFrame(f => !f.includes('did you mean')) + expect(frame).toContain('/comit') // text untouched — never auto-applied + await h.probe.settle() + expect(h.probe.frame()).not.toContain('did you mean') // does not re-open + } finally { + h.probe.destroy() + } + }) + + test('`/xyzzy` (nothing within one edit) → no menu at all', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/xyzzy') + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).not.toContain('did you mean') + expect(frame).not.toContain('Esc dismiss') + } finally { + h.probe.destroy() + } + }) +}) + +describe('anti-jank: mid-prose never completes, never autocorrects', () => { + test('`use /comit here` → no menu, no suggestion, text intact', async () => { + const h = await mountComposer() + try { + // learn the catalog first (a prior slash interaction), then go mid-prose + await h.probe.keys.typeText('/') + await h.probe.settle() + for (let i = 0; i < 1; i++) h.probe.keys.pressBackspace() + await h.probe.settle() + await h.probe.keys.typeText('use /comit here') + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).toContain('use /comit here') // prose untouched + expect(frame).not.toContain('did you mean') + expect(frame).not.toContain('Esc dismiss') // no dropdown of any kind + expect(h.submitted).toEqual([]) + } finally { + h.probe.destroy() + } + }) + + test('`see the a/b path` → nothing (paths are never tokens)', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('see the a/b path') + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).toContain('see the a/b path') + expect(frame).not.toContain('did you mean') + expect(frame).not.toContain('Esc dismiss') + } finally { + h.probe.destroy() + } + }) +}) + +describe('exact-match token highlight (native editBuffer ranges)', () => { + /** Find a styled span whose text contains `needle`. */ + const findSpan = (h: Harness, needle: string) => { + for (const line of h.probe.spans().lines) { + for (const span of line.spans) { + if (span.text.includes(needle)) return span + } + } + return undefined + } + + test('`/help` at start paints the token with the theme accent', async () => { + const h = await mountComposer() + try { + // `/h` first so the prefix batch teaches the catalog (the mock coalesces + // a single typeText into one content change), then complete the name. + await h.probe.keys.typeText('/h') + await h.probe.settle() + await h.probe.keys.typeText('elp') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/help')) + const span = findSpan(h, '/help') + expect(span).toBeDefined() + const accent = RGBA.fromHex(h.store.state.theme.color.accent).toInts() + const [r, g, b] = span!.fg.toInts() + expect([r, g, b]).toEqual(accent.slice(0, 3)) + } finally { + h.probe.destroy() + } + }) + + test('mid-prose exact token gets highlight-only (accent), with NO menu', async () => { + const h = await mountComposer() + try { + // learn the catalog, then clear back to empty + await h.probe.keys.typeText('/') + await h.probe.settle() + h.probe.keys.pressBackspace() + await h.probe.settle() + await h.probe.keys.typeText('use /help here') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('use /help here')) + expect(h.probe.frame()).not.toContain('Esc dismiss') // no menu + const span = findSpan(h, '/help') + expect(span).toBeDefined() + const accent = RGBA.fromHex(h.store.state.theme.color.accent).toInts() + expect(span!.fg.toInts().slice(0, 3)).toEqual(accent.slice(0, 3)) + } finally { + h.probe.destroy() + } + }) + + test('a non-matching token stays unhighlighted (default text color)', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/xyzzy') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/xyzzy')) + const span = findSpan(h, '/xyzzy') + expect(span).toBeDefined() + const accent = RGBA.fromHex(h.store.state.theme.color.accent).toInts() + expect(span!.fg.toInts().slice(0, 3)).not.toEqual(accent.slice(0, 3)) + } finally { + h.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index eb138f62ce95..de14de0b61e5 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -16,6 +16,20 @@ * Tab-only accept so arrows/Enter retain history/cursor/submit meanings. * `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here. * + * Skill highlighting + one-edit autocorrect (Epic 6): standalone `/name` tokens + * whose name exactly matches a valid command/skill name get a native textarea + * highlight (editBuffer.addHighlightByCharRange + a SyntaxStyle — the same + * range-styling seam the extmarks demo uses, WITHOUT ExtmarksController's + * cursor monkey-patching, so the token stays normally editable). The catalog of + * valid names is LEARNED from the slash-completion batches the gateway already + * sends (module-level, survives composer remounts) — the completion flow is the + * source of truth, nothing is hardcoded. When the message is EXACTLY a bare + * lead token one edit away from one valid name (`/comit`) and the gateway menu + * is empty, a synthetic "did you mean" row rides the SAME dropdown (same + * routeMenuKey routing/accept path; Esc dismisses it until the text changes). + * Anti-jank: a `/` mid-prose never completes or autocorrects — exact tokens get + * highlight-only, everything else gets nothing (see logic/skillMatch.ts). + * * Always-active input (item 2): the textarea focuses on mount, on click * (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever * drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav @@ -23,11 +37,12 @@ * the prompt focused via a reactive effect; here a keystroke net is enough since * the composer remounts+refocuses whenever an overlay closes). */ -import { type PasteEvent, type TextareaRenderable } from '@opentui/core' +import { SyntaxStyle, type PasteEvent, type TextareaRenderable } from '@opentui/core' import { useKeyboard } from '@opentui/solid' -import { createEffect, createSignal, For, on, onMount, Show } from 'solid-js' +import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts' +import { analyzeSlash, learnableNames, nativeCharOffset } from '../logic/skillMatch.ts' import type { CompletionItem } from '../logic/store.ts' import type { PromptHistory } from '../logic/history.ts' import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts' @@ -36,6 +51,16 @@ import { useTheme } from './theme.tsx' const GUTTER = 2 +/** Valid command/skill names learned from gateway slash-completion batches + * (Epic 6). Module-level so the catalog survives composer remounts (overlays + * REPLACE the composer); it only ever holds names the gateway itself offered. */ +const LEARNED_NAMES = new Set() + +/** Test hook: reset the learned catalog between cases. */ +export function resetLearnedNames(): void { + LEARNED_NAMES.clear() +} + /** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */ const NAV_KEYS = new Set([ 'return', @@ -103,8 +128,95 @@ export function Composer(props: { let ta: TextareaRenderable | undefined let submitting = false const completions = () => props.completions?.() ?? [] - /** The visible dropdown rows (the menu is capped, selection wraps within it). */ - const menuItems = () => completions().slice(0, MENU_MAX) + /** The gateway's dropdown rows (capped; selection wraps within them). */ + const storeItems = () => completions().slice(0, MENU_MAX) + + // ── skill highlighting + one-edit autocorrect (Epic 6) ──────────────── + // The composer text as a signal (onContentChange keeps it current) so the + // token analysis is reactive; `namesRev` bumps when the learned catalog grows + // (completion batches arrive async, after the text changed). + const [bufText, setBufText] = createSignal('') + const [namesRev, setNamesRev] = createSignal(0) + // Esc on the suggestion row parks it for THIS exact text; any edit re-arms. + const [dismissedFor, setDismissedFor] = createSignal(undefined) + // Learn names from slash-completion batches (bare `/…` lead token only — + // after a space the gateway completes ARGS, not names; see learnableNames). + createEffect( + on( + () => props.completions?.(), + items => { + let grew = false + for (const name of learnableNames(bufText(), items ?? [])) { + if (!LEARNED_NAMES.has(name)) { + LEARNED_NAMES.add(name) + grew = true + } + } + if (grew) setNamesRev(r => r + 1) + } + ) + ) + const analysis = createMemo(() => { + namesRev() // re-analyze when the catalog grows + return analyzeSlash(bufText(), LEARNED_NAMES) + }) + /** The one-edit autocorrect, gated anti-jank: only while the gateway menu is + * EMPTY (a live prefix menu always wins) and not Esc-dismissed for this text. */ + const suggested = () => { + const s = analysis().suggestion + return s && dismissedFor() !== bufText() ? s : undefined + } + /** The visible dropdown rows: the gateway menu, else the synthetic suggestion. */ + const menuItems = (): CompletionItem[] => { + const items = storeItems() + if (items.length > 0) return items + const s = suggested() + return s ? [{ display: `/${s.name}`, meta: 'did you mean? (Tab/Enter to accept)', text: s.name }] : [] + } + + // Native highlight plumbing: one SyntaxStyle per mount holding the token + // style; ranges are recomputed from `analysis()` on every change (clear+add — + // the same recompute model ExtmarksController uses). Best-effort: a native + // styling failure must never take the composer down. + let syntax: SyntaxStyle | undefined + let tokenStyleId = 0 + onMount(() => { + try { + const style = SyntaxStyle.create() + tokenStyleId = style.registerStyle('slash-token', { bold: true, fg: theme().color.accent }) + if (ta) ta.syntaxStyle = style + syntax = style + } catch { + syntax = undefined + } + }) + onCleanup(() => { + try { + if (ta && !ta.isDestroyed) ta.syntaxStyle = null + syntax?.destroy() + } catch { + /* teardown is best-effort */ + } + syntax = undefined + }) + createEffect(() => { + const a = analysis() + if (!ta || !syntax || ta.isDestroyed) return + try { + const text = bufText() + ta.editBuffer.clearAllHighlights() + for (const t of a.highlights) { + ta.editBuffer.addHighlightByCharRange({ + end: nativeCharOffset(text, t.end), + start: nativeCharOffset(text, t.start), + styleId: tokenStyleId + }) + } + ta.requestRender() + } catch { + /* highlight is cosmetic — never crash on a native hiccup */ + } + }) // Highlighted dropdown row (Epic 8). New candidates (every refine keystroke // swaps the array) reset it to the top match. const [selected, setSelected] = createSignal(0) @@ -133,7 +245,10 @@ export function Composer(props: { const acceptCompletion = (index: number) => { const item = menuItems()[index] ?? menuItems()[0] if (!item || !ta) return - const from = props.completionFrom?.() ?? 0 + // A synthetic suggestion row (gateway menu empty) replaces from just past + // the `/` (its own `from`); gateway rows keep the store's replace_from. + const synthetic = storeItems().length === 0 + const from = synthetic ? (suggested()?.from ?? 1) : (props.completionFrom?.() ?? 0) const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length)) setBuffer(before + item.text + ' ') props.onDismiss?.() @@ -179,6 +294,9 @@ export function Composer(props: { return } if (action.kind === 'dismiss') { + // also park the synthetic suggestion for this exact text (Esc must not + // re-open it on the next analysis pass); any edit re-arms it. + setDismissedFor(ta?.plainText ?? '') props.onDismiss?.() return } @@ -241,7 +359,7 @@ export function Composer(props: { return ( - 0}> + 0}> { const text = ta?.plainText ?? '' setSlashText(text.startsWith('/')) + setBufText(text) // drives the token analysis (highlight + suggestion) props.onType?.(text) }} />