opentui(v6): skill highlighting + one-edit autocorrect (anti-jank)

This commit is contained in:
alt-glitch 2026-06-10 21:27:36 +05:30
parent eaad47a6f6
commit df4bdc9d58
5 changed files with 723 additions and 6 deletions

View file

@ -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<string>): 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
}

View file

@ -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<string>
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) => {

View file

@ -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> = {}): 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)
})
})

View file

@ -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<Harness> {
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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} onSubmit={t => submitted.push(t)} onType={onType} history={history} />
</ThemeProvider>
),
{ 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()
}
})
})

View file

@ -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<string>()
/** 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<string | undefined>(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 (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Show when={completions().length > 0}>
<Show when={menuItems().length > 0}>
<box
style={{
backgroundColor: theme().color.completionBg,
@ -315,6 +433,7 @@ export function Composer(props: {
onContentChange={() => {
const text = ta?.plainText ?? ''
setSlashText(text.startsWith('/'))
setBufText(text) // drives the token analysis (highlight + suggestion)
props.onType?.(text)
}}
/>