opentui(v2): slash-arg autocomplete + file/@-mention completion (items 5, 13)

onType used to fire complete.slash only for an argless `/command`, and Tab
replaced the whole line. Now:

- planCompletion(text) (pure, in slash.ts) routes: a `/command [args]` line →
  complete.slash (the gateway completes names AND args, e.g. /details section
  names); a trailing path-like word (@…, ~/…, ./…, /…, or anything with /) →
  complete.path for file/dir tagging; else nothing.
- the accepted item splices ONLY its token: store tracks completionFrom (gateway
  replace_from via readReplaceFrom, or the path-token start), and the composer's
  Tab handler keeps the text before `from` and appends the candidate.

Tests: planCompletion (slash/path/prose/multiline) + readReplaceFrom. 69 pass.

Live-smoked: `/details ` → section dropdown (hidden/collapsed/.../activity), Tab
→ `/details hidden` (arg-only splice); `tui_gateway/` → its .py files;
`@hermes_cli/m` → m-prefixed files.
This commit is contained in:
alt-glitch 2026-06-08 17:57:07 +00:00
parent 1ecec7a9bc
commit 2bb61a7d09
6 changed files with 126 additions and 14 deletions

View file

@ -29,7 +29,7 @@ import { acquireRenderer } from '../boundary/renderer.ts'
import { makeAppLayer } from '../boundary/runtime.ts'
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
import { dispatchSlash, mapCompletions, type SlashContext } from '../logic/slash.ts'
import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts'
import { createSessionStore, type SessionStore } from '../logic/store.ts'
import { App } from '../view/App.tsx'
import { ThemeProvider } from '../view/theme.tsx'
@ -265,16 +265,20 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
else submitPrompt(text)
}
// Live slash completions: query `complete.slash` while typing a `/command`
// name (no space yet); clear otherwise. Cheap local completer — fired per
// keystroke (a debounce is a polish item).
// Live completions (items 5 + 13): a `/command [args]` line queries
// `complete.slash` (the gateway completes names AND args); a trailing
// path-like word queries `complete.path` (file/@-mention tagging). The
// accepted item replaces from the gateway's `replace_from` (or the token
// start), so only the relevant token is spliced — not the whole line.
// Fired per keystroke (a debounce is a polish item).
const onType = (text: string) => {
if (!text.startsWith('/') || text.includes(' ') || text.includes('\n')) {
const plan = planCompletion(text)
if (!plan) {
store.clearCompletions()
return
}
Effect.runPromise(gateway.request('complete.slash', { text }))
.then(result => store.setCompletions(mapCompletions(result)))
Effect.runPromise(gateway.request(plan.method, plan.params))
.then(result => store.setCompletions(mapCompletions(result), readReplaceFrom(result, plan.from)))
.catch(() => store.clearCompletions())
}

View file

@ -61,7 +61,55 @@ function readStr(value: unknown, key: string): string | undefined {
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
/** Map a `complete.slash` result ({items:[{text,display,meta}]}) into completion candidates. */
/** A planned completion query (item 5/13): which RPC + params, and where an
* accepted item replaces from if the RPC omits its own `replace_from`. */
export interface CompletionPlan {
method: 'complete.slash' | 'complete.path'
params: Record<string, unknown>
from: number
}
/** A path-like last token worth file/@-mention completion (mirrors Ink's TAB_PATH_RE intent). */
function isPathLike(word: string): boolean {
return (
word.startsWith('@') ||
word.startsWith('~') ||
word.startsWith('./') ||
word.startsWith('../') ||
word.startsWith('/') ||
word.includes('/')
)
}
/**
* Decide what to complete for the current composer text (cursor assumed at end):
* - `/command [args]` `complete.slash {text}` (the gateway completes names AND
* args, e.g. /details section names),
* - a trailing path-like word (`@…`, `~/…`, `./…`, `/…`, or anything with `/`)
* `complete.path {word}` for file/dir tagging,
* - otherwise nothing.
* Returns null when there's no completion to run (so the dropdown clears).
*/
export function planCompletion(text: string): CompletionPlan | null {
if (text.includes('\n')) return null
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } }
const word = /(\S+)$/.exec(text)?.[1]
if (word && isPathLike(word)) {
return { from: text.length - word.length, method: 'complete.path', params: { word } }
}
return null
}
/** Read a `replace_from` offset off a completion result, falling back to `fallback`. */
export function readReplaceFrom(result: unknown, fallback: number): number {
if (result && typeof result === 'object') {
const rf = (result as { replace_from?: unknown }).replace_from
if (typeof rf === 'number') return rf
}
return fallback
}
/** Map a `complete.slash`/`complete.path` result ({items:[{text,display,meta}]}) into candidates. */
export function mapCompletions(result: unknown): CompletionItem[] {
if (!result || typeof result !== 'object') return []
const items = (result as { items?: unknown }).items

View file

@ -137,8 +137,11 @@ export interface StoreState {
switcher: SessionItem[] | undefined
/** The open generic picker (model/skills/…); undefined when none. */
picker: PickerState | undefined
/** Live slash-completion candidates shown above the composer; undefined/empty when none. */
/** Live completion candidates (slash-name/args or file/@-mention) shown above the composer. */
completions: CompletionItem[] | undefined
/** Char offset in the input where an accepted completion should start replacing
* (gateway `replace_from` for slash args; the path-token start for @-mentions). */
completionFrom: number
/** Delegated subagents (from `subagent.*`), shown in the agents dashboard. */
subagents: SubagentInfo[]
/** Whether the agents dashboard overlay is open (/agents). */
@ -231,6 +234,7 @@ export function createSessionStore() {
switcher: undefined,
picker: undefined,
completions: undefined,
completionFrom: 0,
subagents: [],
dashboard: false,
status: undefined,
@ -379,12 +383,15 @@ export function createSessionStore() {
if (Object.keys(patch).length) setState('info', prev => ({ ...prev, ...patch }))
}
/** Set / clear the live slash-completion candidates (composer dropdown). */
function setCompletions(items: CompletionItem[]) {
/** Set / clear the live completion candidates (composer dropdown). `from` is the
* input char offset an accepted item replaces from (slash-arg / @-mention splice). */
function setCompletions(items: CompletionItem[], from = 0) {
setState('completions', items.length ? items : undefined)
setState('completionFrom', items.length ? Math.max(0, from) : 0)
}
function clearCompletions() {
setState('completions', undefined)
setState('completionFrom', 0)
}
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */

View file

@ -4,7 +4,14 @@
*/
import { describe, expect, test } from 'bun:test'
import { dispatchSlash, mapCompletions, parseSlash, type SlashContext } from '../logic/slash.ts'
import {
dispatchSlash,
mapCompletions,
parseSlash,
planCompletion,
readReplaceFrom,
type SlashContext
} from '../logic/slash.ts'
import type { PickerItem, SessionItem } from '../logic/store.ts'
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
@ -22,6 +29,47 @@ describe('mapCompletions', () => {
})
})
describe('planCompletion (items 5 + 13)', () => {
test('a slash line → complete.slash with the full text (name AND args)', () => {
expect(planCompletion('/mod')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/mod' } })
// args too — the gateway completes e.g. /details section names
expect(planCompletion('/details thi')).toEqual({
from: 0,
method: 'complete.slash',
params: { text: '/details thi' }
})
})
test('a trailing path-like word → complete.path with that word + token start offset', () => {
expect(planCompletion('explain @src/fo')).toEqual({
from: 'explain '.length,
method: 'complete.path',
params: { word: '@src/fo' }
})
expect(planCompletion('cat ./rea')).toEqual({ from: 'cat '.length, method: 'complete.path', params: { word: './rea' } })
expect(planCompletion('open ~/proj')).toEqual({
from: 'open '.length,
method: 'complete.path',
params: { word: '~/proj' }
})
})
test('plain prose / multiline → no completion', () => {
expect(planCompletion('just some words')).toBeNull()
expect(planCompletion('hello')).toBeNull()
expect(planCompletion('/cmd with\nnewline')).toBeNull()
})
})
describe('readReplaceFrom', () => {
test('reads gateway replace_from, falls back when absent/non-number', () => {
expect(readReplaceFrom({ items: [], replace_from: 9 }, 0)).toBe(9)
expect(readReplaceFrom({ items: [] }, 4)).toBe(4)
expect(readReplaceFrom({ replace_from: 'nope' }, 7)).toBe(7)
expect(readReplaceFrom(null, 2)).toBe(2)
})
})
describe('parseSlash', () => {
test('splits name + arg; rejects non-slash / empty', () => {
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })

View file

@ -86,6 +86,7 @@ export function App(props: AppProps) {
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
completionFrom={() => props.store.state.completionFrom}
onDismiss={() => props.store.clearCompletions()}
history={props.history}
/>

View file

@ -75,6 +75,7 @@ export function Composer(props: {
onSubmit: (text: string) => void
onType?: ((text: string) => void) | undefined
completions?: (() => CompletionItem[]) | undefined
completionFrom?: (() => number) | undefined
onDismiss?: (() => void) | undefined
history?: PromptHistory | undefined
}) {
@ -108,8 +109,11 @@ export function Composer(props: {
if (key.name === 'tab') {
const top = completions()[0]
if (top && ta) {
ta.clear()
ta.insertText(top.text + ' ')
// splice only the token being completed (slash-arg / @-mention), not the
// whole line — `completionFrom` is the gateway's replace_from / token start.
const from = props.completionFrom?.() ?? 0
const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length))
setBuffer(before + top.text + ' ')
props.onDismiss?.()
}
return