From 2bb61a7d09ad54ddb00325bdd99dee77576d65bd Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 17:57:07 +0000 Subject: [PATCH] opentui(v2): slash-arg autocomplete + file/@-mention completion (items 5, 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui-opentui-v2/src/entry/main.tsx | 18 +++++---- ui-tui-opentui-v2/src/logic/slash.ts | 50 +++++++++++++++++++++++- ui-tui-opentui-v2/src/logic/store.ts | 13 ++++-- ui-tui-opentui-v2/src/test/slash.test.ts | 50 +++++++++++++++++++++++- ui-tui-opentui-v2/src/view/App.tsx | 1 + ui-tui-opentui-v2/src/view/composer.tsx | 8 +++- 6 files changed, 126 insertions(+), 14 deletions(-) diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index caf58f7e0ce..3f01edd79f9 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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()) } diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts index 2401888a7f8..188d7f94989 100644 --- a/ui-tui-opentui-v2/src/logic/slash.ts +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -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 + 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 diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index d0c3a85485e..2cdea4f2d47 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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. */ diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index f094e65d050..0cf1eda32e4 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -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: '' }) diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index e00825de5cb..4ea0b418eeb 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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} /> diff --git a/ui-tui-opentui-v2/src/view/composer.tsx b/ui-tui-opentui-v2/src/view/composer.tsx index abdb737f930..527df626b00 100644 --- a/ui-tui-opentui-v2/src/view/composer.tsx +++ b/ui-tui-opentui-v2/src/view/composer.tsx @@ -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