From 530e7c0d4eb39afd178f6059ba60e59ad4a140b0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 21:19:16 -0500 Subject: [PATCH 1/3] fix(tui): offer skill completions for a slash typed mid-message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slash only opened completions at position 0, so `please run /cle` suggested nothing. That is correct for execution — commands only run from the start of a message — but it also removed the ability to reference a skill inside prose. Position 0 and mid-message are now detected separately. A leading slash stays a command invocation with arg completion and the full command set; a slash after whitespace is an inline skill reference. Only skills are offered there, since a built-in like /model or /new acts on the app and reads as nothing useful inside a sentence. The gateway tags each completion with its kind, derived from the same skill-command and skill-bundle providers the completer already consumes, so the TUI filters on data rather than sniffing the display meta glyphs. applyCompletion keyed its leading-slash check off the start of the input, which is only the replace point for a position-0 command. It now keys off the character before compReplace, so an inline pick lands as `please run /clean` instead of `please run //clean`. The Tab handler carried its own divergent copy of that logic and now calls the shared helper. --- tui_gateway/server.py | 17 ++++++ ui-tui/src/app/interfaces.ts | 3 ++ ui-tui/src/app/useInputHandlers.ts | 8 +-- ui-tui/src/domain/slash.ts | 87 ++++++++++++++++++++++++++++-- ui-tui/src/gatewayTypes.ts | 3 ++ ui-tui/src/hooks/useCompletion.ts | 51 ++++++++++++++---- 6 files changed, 149 insertions(+), 20 deletions(-) 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/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/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) { From 07244c5ead7c1b4fd1b9027e2d9303ad4461a9f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 21:19:20 -0500 Subject: [PATCH 2/3] fix(tui): keep a referenced skill styled in the sent message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer offers a mid-prose /skill as a completion, but the transcript knew nothing about slash references and rendered the whole user message as one flat run of text, so the skill lost its accent the moment it was sent. User messages are now split into plain and reference runs, and the reference keeps the accent it wore in the composer. The text is unchanged — concatenating the segments reproduces the input exactly — so this is presentation only, and the backend still receives the literal /clean. The scan has to reject a token that continues into a path, since it runs over finished text rather than anchoring at the caret: /usr/local/bin would otherwise style as /usr. --- ui-tui/src/components/messageLine.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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} })() From ec6fa9bdb255ec71a2ed809f4df323bd67f3b03a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 21:19:20 -0500 Subject: [PATCH 3/3] test(tui): cover inline skill references end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inlineSlashTrigger and completionRequestForInput are driven through the mid-message shape, the position-0 shape, and the path cases that must not claim a slash (/usr/local/bin, src/foo/bar, and/or, 3 /4). splitSlashSkillRefs asserts the round-trip invariant — the segments always rejoin to the exact input — rather than freezing a segment list. The applyCompletion cases reproduce the reported bug rather than restating the implementation: reverting the fix fails with `expected 'please run //clean' to be 'please run /clean'`. --- ui-tui/src/__tests__/completionApply.test.ts | 12 ++ ui-tui/src/__tests__/inlineSlashSkill.test.ts | 112 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 ui-tui/src/__tests__/inlineSlashSkill.test.ts 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: '' }]) + }) +})