diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 06c15641d97..16daf7b9676 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/src/__tests__/completionApply.test.ts b/ui-tui/src/__tests__/completionApply.test.ts index 5b26f8810d3..b8870a94278 100644 --- a/ui-tui/src/__tests__/completionApply.test.ts +++ b/ui-tui/src/__tests__/completionApply.test.ts @@ -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', () => { diff --git a/ui-tui/src/__tests__/inlineSlashSkill.test.ts b/ui-tui/src/__tests__/inlineSlashSkill.test.ts new file mode 100644 index 00000000000..d2f81b7059f --- /dev/null +++ b/ui-tui/src/__tests__/inlineSlashSkill.test.ts @@ -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: '' }]) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 44ff7fa2a2c..485c0ec28e2 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 83c71285037..f9461919915 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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 diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index d392ca94583..09b1c78a1ad 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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 ( + + {segments.map((segment, i) => + segment.ref ? ( + + {segment.text} + + ) : ( + segment.text + ) + )} + + ) + } + return {msg.text} })() diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index b86c34d134c..b40578c9604 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -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 } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ff287440120..719ffe9938d 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 } diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 5e0dc33c540..468c8995b03 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -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(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) {