Merge pull request #71697 from NousResearch/bb/tui-slash-trigger

fix(tui): make skills referenceable anywhere in the composer
This commit is contained in:
brooklyn! 2026-07-25 21:38:12 -05:00 • committed by GitHub
commit 46cae787ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 295 additions and 20 deletions

View file

@ -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:

View file

@ -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', () => {

View 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: '' }])
})
})

View file

@ -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
}

View file

@ -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

View file

@ -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>
})()

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {