mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
opentui(v6): skill highlighting + one-edit autocorrect (anti-jank)
This commit is contained in:
parent
eaad47a6f6
commit
df4bdc9d58
5 changed files with 723 additions and 6 deletions
161
ui-opentui/src/logic/skillMatch.ts
Normal file
161
ui-opentui/src/logic/skillMatch.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
180
ui-opentui/src/test/skillMatch.test.ts
Normal file
180
ui-opentui/src/test/skillMatch.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
252
ui-opentui/src/test/skillSuggest.test.tsx
Normal file
252
ui-opentui/src/test/skillSuggest.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue