mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
1ecec7a9bc
commit
2bb61a7d09
6 changed files with 126 additions and 14 deletions
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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: '' })
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue