mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #71697 from NousResearch/bb/tui-slash-trigger
fix(tui): make skills referenceable anywhere in the composer
This commit is contained in:
commit
46cae787ab
9 changed files with 295 additions and 20 deletions
|
|
@ -15806,6 +15806,14 @@ def _(rid, params: dict) -> dict:
|
|||
skill_bundles_provider=lambda: get_skill_bundles(),
|
||||
)
|
||||
doc = Document(text, len(text))
|
||||
# Skill commands and bundles are the only completions offered for an
|
||||
# inline `/skill` reference typed mid-message, so the class has to
|
||||
# reach the TUI as data. Derived from the same providers the completer
|
||||
# uses — no sniffing the ⚡/▣ meta glyphs, which are display text.
|
||||
skill_names = {
|
||||
key.lstrip("/").lower()
|
||||
for key in (*get_skill_commands(), *get_skill_bundles())
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"text": c.text,
|
||||
|
|
@ -15816,6 +15824,11 @@ def _(rid, params: dict) -> dict:
|
|||
# layout into 1-char truncation of the next column.
|
||||
"display": to_plain_text(c.display) if c.display else c.text,
|
||||
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
|
||||
"kind": (
|
||||
"skill"
|
||||
if c.text.strip().lstrip("/").lower() in skill_names
|
||||
else "command"
|
||||
),
|
||||
}
|
||||
for c in completer.get_completions(doc, None)
|
||||
][:30]
|
||||
|
|
@ -15825,21 +15838,25 @@ def _(rid, params: dict) -> dict:
|
|||
"text": "/density",
|
||||
"display": "/density",
|
||||
"meta": "Toggle compact display mode",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/details",
|
||||
"display": "/details",
|
||||
"meta": "Control agent detail visibility",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/logs",
|
||||
"display": "/logs",
|
||||
"meta": "Show recent gateway log lines",
|
||||
"kind": "command",
|
||||
},
|
||||
{
|
||||
"text": "/mouse",
|
||||
"display": "/mouse",
|
||||
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
|
||||
"kind": "command",
|
||||
},
|
||||
]
|
||||
for extra in extras:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@ describe('applyCompletion', () => {
|
|||
it('replaces an argument token after a space (subcommand completion)', () => {
|
||||
expect(applyCompletion('/cron ad', 'add', 6)).toBe('/cron add')
|
||||
})
|
||||
|
||||
it('applies an inline skill pick without disturbing the prose in front of it', () => {
|
||||
// The gateway returns bare names for `complete.slash`; a mid-message
|
||||
// reference replaces from just after its own `/`, so "please run " stays.
|
||||
expect(applyCompletion('please run /cle', 'clean', 12)).toBe('please run /clean')
|
||||
})
|
||||
|
||||
it('drops the row slash based on the character before the replace point, not the input start', () => {
|
||||
// Widget-app rows carry a leading slash. Mid-message the input does NOT
|
||||
// start with `/`, so a start-anchored check would double it up.
|
||||
expect(applyCompletion('please run /cle', '/clean', 12)).toBe('please run /clean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionToApplyOnSubmit', () => {
|
||||
|
|
|
|||
112
ui-tui/src/__tests__/inlineSlashSkill.test.ts
Normal file
112
ui-tui/src/__tests__/inlineSlashSkill.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { inlineSlashTrigger, splitSlashSkillRefs } from '../domain/slash.js'
|
||||
import { completionRequestForInput } from '../hooks/useCompletion.js'
|
||||
|
||||
describe('inlineSlashTrigger', () => {
|
||||
it('detects a slash typed mid-message', () => {
|
||||
// The reported bug: only a position-0 slash offered anything, so
|
||||
// "please run /cle" completed nothing.
|
||||
expect(inlineSlashTrigger('please run /cle')).toEqual({ query: 'cle', start: 11 })
|
||||
})
|
||||
|
||||
it('detects a bare slash after whitespace, before any name is typed', () => {
|
||||
expect(inlineSlashTrigger('please run /')).toEqual({ query: '', start: 11 })
|
||||
})
|
||||
|
||||
it('fires after a newline, not just a space', () => {
|
||||
expect(inlineSlashTrigger('text\n/skill')).toEqual({ query: 'skill', start: 5 })
|
||||
})
|
||||
|
||||
it('does not fire at position 0 — that is a command invocation', () => {
|
||||
expect(inlineSlashTrigger('/clean')).toBeNull()
|
||||
expect(inlineSlashTrigger('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves file paths alone', () => {
|
||||
expect(inlineSlashTrigger('look at /usr/local/bin')).toBeNull()
|
||||
expect(inlineSlashTrigger('check src/foo/bar')).toBeNull()
|
||||
expect(inlineSlashTrigger('and/or')).toBeNull()
|
||||
})
|
||||
|
||||
it('stops at the command token — an inline reference takes no args', () => {
|
||||
// Only a position-0 slash is a real invocation, so `/personality alic`
|
||||
// mid-message is prose with a reference in it, already ended.
|
||||
expect(inlineSlashTrigger('hello there /personality alic')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a start index that replaces only the typed token', () => {
|
||||
const text = 'please run /cle'
|
||||
const trigger = inlineSlashTrigger(text)!
|
||||
|
||||
expect(text.slice(0, trigger.start)).toBe('please run ')
|
||||
expect(text.slice(trigger.start)).toBe('/cle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionRequestForInput — inline skill references', () => {
|
||||
it('asks for skills only when the slash is mid-message', () => {
|
||||
const request = completionRequestForInput('please run /cle')
|
||||
|
||||
expect(request).toMatchObject({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/cle' },
|
||||
replaceFrom: 12,
|
||||
skillsOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the full command set at position 0', () => {
|
||||
expect(completionRequestForInput('/cle')).toEqual({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/cle' },
|
||||
replaceFrom: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a real mid-message path to path completion, not skills', () => {
|
||||
expect(completionRequestForInput('open src/foo/ba')).toMatchObject({ method: 'complete.path' })
|
||||
expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitSlashSkillRefs', () => {
|
||||
it('marks a skill referenced mid-prose', () => {
|
||||
expect(splitSlashSkillRefs('clean this up with /clean')).toEqual([
|
||||
{ ref: false, text: 'clean this up with ' },
|
||||
{ ref: true, text: '/clean' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the prose on both sides of the reference', () => {
|
||||
expect(splitSlashSkillRefs('run /clean then ship')).toEqual([
|
||||
{ ref: false, text: 'run ' },
|
||||
{ ref: true, text: '/clean' },
|
||||
{ ref: false, text: ' then ship' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mark paths', () => {
|
||||
for (const text of ['look at /usr/local/bin', 'check src/foo/bar', 'a 3 /4 b']) {
|
||||
expect(splitSlashSkillRefs(text)).toEqual([{ ref: false, text }])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mark a leading slash — that is a command, not a reference', () => {
|
||||
expect(splitSlashSkillRefs('/clean')).toEqual([{ ref: false, text: '/clean' }])
|
||||
})
|
||||
|
||||
it('round-trips the input exactly', () => {
|
||||
for (const text of ['run /clean then /work ok', 'plain text', '', 'look at /usr/local/bin']) {
|
||||
expect(
|
||||
splitSlashSkillRefs(text)
|
||||
.map(s => s.text)
|
||||
.join('')
|
||||
).toBe(text)
|
||||
}
|
||||
})
|
||||
|
||||
it('always returns at least one segment', () => {
|
||||
expect(splitSlashSkillRefs('')).toEqual([{ ref: false, text: '' }])
|
||||
})
|
||||
})
|
||||
|
|
@ -88,6 +88,9 @@ export interface SelectionApi {
|
|||
|
||||
export interface CompletionItem {
|
||||
display: string
|
||||
/** Completion class from the gateway; `skill` is the only kind offered for
|
||||
* an inline `/skill` reference typed mid-message. */
|
||||
kind?: string
|
||||
meta?: string
|
||||
text: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
|
|||
|
||||
import { DASHBOARD_TUI_MODE } from '../config/env.js'
|
||||
import { TYPING_IDLE_MS } from '../config/timing.js'
|
||||
import { applyCompletion } from '../domain/slash.js'
|
||||
import type {
|
||||
ApprovalRespondResponse,
|
||||
ConfigSetResponse,
|
||||
|
|
@ -640,12 +641,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
const row = cState.completions[cState.compIdx]
|
||||
|
||||
if (row?.text) {
|
||||
const text =
|
||||
cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0
|
||||
? row.text.slice(1)
|
||||
: row.text
|
||||
|
||||
cActions.setInput(cState.input.slice(0, cState.compReplace) + text)
|
||||
cActions.setInput(applyCompletion(cState.input, row.text, cState.compReplace))
|
||||
}
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { hasLeadGap } from '../domain/blockLayout.js'
|
|||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { splitSlashSkillRefs } from '../domain/slash.js'
|
||||
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
|
|
@ -204,6 +205,27 @@ export const MessageLine = memo(function MessageLine({
|
|||
)
|
||||
}
|
||||
|
||||
// A skill the user referenced mid-prose (`clean this up with /clean`)
|
||||
// keeps the accent it wore as a completion in the composer, instead of
|
||||
// flattening back into the body text.
|
||||
if (msg.role === 'user') {
|
||||
const segments = splitSlashSkillRefs(msg.text)
|
||||
|
||||
return (
|
||||
<Text {...(body ? { color: body } : {})}>
|
||||
{segments.map((segment, i) =>
|
||||
segment.ref ? (
|
||||
<Text color={t.color.accent} key={i}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
) : (
|
||||
segment.text
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
|
||||
})()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,21 +10,100 @@ export const sessionScopedModelArg = (value: string) => {
|
|||
|
||||
export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
|
||||
|
||||
// A `/` means two different things depending on where it sits:
|
||||
//
|
||||
// - At position 0 it's a COMMAND invocation the TUI executes
|
||||
// (`looksLikeSlashCommand`, and the backend's dispatch, are both
|
||||
// `^`-anchored). Completion stays live past the command name so arg
|
||||
// completion works (`/cron ad` → `add`).
|
||||
// - After whitespace it's an inline SKILL reference the user is dropping into
|
||||
// prose ("clean this up with /clean"). The text submits as an ordinary
|
||||
// message, so there are no args to complete — the token ends at the caret.
|
||||
//
|
||||
// The inline shape is what makes skills reachable anywhere in a prompt. The
|
||||
// trailing `$` matters for both: completion runs against the text the user has
|
||||
// typed so far, so the match has to end where they're typing.
|
||||
//
|
||||
// Requiring a single bare segment (no second `/`) keeps real paths out:
|
||||
// `look at /usr/local/bin` and `check src/foo/bar` never match. A bare `/us` is
|
||||
// genuinely ambiguous with an absolute path, and resolves as a skill reference
|
||||
// — typing the next `/` flips it straight back to path completion.
|
||||
const INLINE_SLASH_RE = /\s\/([a-zA-Z][\w-]*)?$/
|
||||
|
||||
/**
|
||||
* Locate an inline `/skill` reference at the end of `text`, or null when the
|
||||
* text isn't one. `start` is the index of the `/` itself, so a completion
|
||||
* replaces the typed token and leaves the prose in front of it untouched.
|
||||
*/
|
||||
export const inlineSlashTrigger = (text: string): { query: string; start: number } | null => {
|
||||
const match = INLINE_SLASH_RE.exec(text)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const query = match[1] ?? ''
|
||||
|
||||
return { query, start: text.length - query.length - 1 }
|
||||
}
|
||||
|
||||
export const parseSlashCommand = (cmd: string) => {
|
||||
const [name = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||
|
||||
return { arg: rest.join(' '), cmd, name: name.toLowerCase() }
|
||||
}
|
||||
|
||||
// A skill referenced mid-prose in a message that's already been sent
|
||||
// ("clean this up with /clean"). The composer offers it as a completion, so
|
||||
// the transcript marks it as one rather than flattening it into the body text.
|
||||
//
|
||||
// Unlike the caret-anchored trigger above this scans finished text, so it has
|
||||
// to reject a token that continues into a path: `/usr/local/bin` would
|
||||
// otherwise mark `/usr`. `(?![\w-]*\/)` requires the token to end at something
|
||||
// other than another slash. A leading `/` is excluded too — that's a command
|
||||
// invocation, which never reaches the transcript as a user message.
|
||||
const SLASH_SKILL_REF_RE = /(?<=\s)\/[a-zA-Z][\w-]*(?![\w-]*\/)/g
|
||||
|
||||
/**
|
||||
* Split `text` into alternating plain and `/skill` reference runs. Always
|
||||
* returns at least one segment, and concatenating every `text` reproduces the
|
||||
* input exactly — the transcript styles the reference without rewriting it.
|
||||
*/
|
||||
export const splitSlashSkillRefs = (text: string): { ref: boolean; text: string }[] => {
|
||||
const out: { ref: boolean; text: string }[] = []
|
||||
let last = 0
|
||||
|
||||
for (const match of text.matchAll(SLASH_SKILL_REF_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > last) {
|
||||
out.push({ ref: false, text: text.slice(last, start) })
|
||||
}
|
||||
|
||||
out.push({ ref: true, text: match[0] })
|
||||
last = start + match[0].length
|
||||
}
|
||||
|
||||
if (last < text.length || !out.length) {
|
||||
out.push({ ref: false, text: text.slice(last) })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a completion row to the current input, mirroring the editor's
|
||||
* replace semantics: replace from `compReplace` with the row text, dropping
|
||||
* the leading slash when both the input and the row carry one (the gateway's
|
||||
* slash completer returns bare command names whose replace span begins after
|
||||
* the leading `/`).
|
||||
* the row's leading slash when the input already has one immediately before
|
||||
* the replace point (the gateway's slash completer returns bare command names
|
||||
* whose replace span begins after the leading `/`).
|
||||
*
|
||||
* Keyed off the character before `compReplace` rather than the start of the
|
||||
* input so a `/token` anywhere in the message behaves the same as one at
|
||||
* position 0 — an inline `run /cle` replaces from after its own slash.
|
||||
*/
|
||||
export const applyCompletion = (value: string, rowText: string, compReplace: number): string => {
|
||||
const text = value.startsWith('/') && rowText.startsWith('/') ? rowText.slice(1) : rowText
|
||||
const text = value[compReplace - 1] === '/' && rowText.startsWith('/') ? rowText.slice(1) : rowText
|
||||
|
||||
return value.slice(0, compReplace) + text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export type GatewaySkin = HermesSkin
|
|||
|
||||
export interface GatewayCompletionItem {
|
||||
display: string
|
||||
/** Completion class, set by the gateway. `skill` covers skill commands and
|
||||
* skill bundles — the only kind offered for an inline `/skill` reference. */
|
||||
kind?: string
|
||||
meta?: string
|
||||
text: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { CompletionItem } from '../app/interfaces.js'
|
||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import { inlineSlashTrigger, looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { CompletionResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
|
@ -30,15 +30,11 @@ export function completionRequestForInput(
|
|||
input: string
|
||||
):
|
||||
| { method: 'complete.path'; params: { word: string }; replaceFrom: number }
|
||||
| { method: 'complete.slash'; params: { text: string }; replaceFrom: number }
|
||||
| { method: 'complete.slash'; params: { text: string }; replaceFrom: number; skillsOnly?: boolean }
|
||||
| null {
|
||||
const isSlashCommand = looksLikeSlashCommand(input)
|
||||
const pathWord = isSlashCommand ? null : (input.match(TAB_PATH_RE)?.[1] ?? null)
|
||||
|
||||
if (!isSlashCommand && !pathWord) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `/model` uses the two-step ModelPicker (real curated IDs).
|
||||
// Slash completion here only showed short aliases + vendor/family meta.
|
||||
if (isSlashCommand && /^\/model(?:\s|$)/.test(input)) {
|
||||
|
|
@ -49,10 +45,30 @@ export function completionRequestForInput(
|
|||
return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 }
|
||||
}
|
||||
|
||||
// A `/token` mid-message is a skill reference dropped into prose. It's only
|
||||
// reachable here because the path branch below would otherwise claim it: a
|
||||
// bare `/cle` matches TAB_PATH_RE as an absolute path. Skills win that tie —
|
||||
// the moment a second `/` is typed the inline trigger stops matching and
|
||||
// path completion takes back over.
|
||||
const inline = inlineSlashTrigger(input)
|
||||
|
||||
if (inline) {
|
||||
return {
|
||||
method: 'complete.slash',
|
||||
params: { text: `/${inline.query}` },
|
||||
replaceFrom: inline.start + 1,
|
||||
skillsOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
if (!pathWord) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
method: 'complete.path',
|
||||
params: { word: pathWord! },
|
||||
replaceFrom: input.length - pathWord!.length
|
||||
params: { word: pathWord },
|
||||
replaceFrom: input.length - pathWord.length
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +119,27 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
|
||||
const r = asRpcResult<CompletionResponse>(raw)
|
||||
|
||||
const items =
|
||||
const fetched =
|
||||
request.method === 'complete.slash' ? mergeWidgetAppItems(input, r?.items ?? []) : (r?.items ?? [])
|
||||
|
||||
// Mid-message offers SKILLS only. A built-in like `/model` or `/new`
|
||||
// acts on the app, so it's meaningless as a reference inside prose —
|
||||
// only a skill reads as "handle this part with X". Filtering here
|
||||
// rather than in the gateway keeps one completion source for both
|
||||
// shapes.
|
||||
const items =
|
||||
request.method === 'complete.slash' && request.skillsOnly
|
||||
? fetched.filter(item => item.kind === 'skill')
|
||||
: fetched
|
||||
|
||||
setCompletions(items)
|
||||
setCompIdx(0)
|
||||
setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom)
|
||||
// An inline reference replaces its own token, so the gateway's
|
||||
// `replace_from` (an offset into the synthetic `/query` it was sent)
|
||||
// doesn't apply — the caller already knows where the token starts.
|
||||
setCompReplace(
|
||||
request.method === 'complete.slash' && !request.skillsOnly ? (r?.replace_from ?? 1) : request.replaceFrom
|
||||
)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (ref.current !== input) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue