feat(desktop): recognize slash commands in text the composer didn't watch typed

The composer chips a `/command` when it's picked or accepted from the
popover. Text that arrives whole — a paste, a restored draft, an undo
step — never passes through that path, so nothing recognizes the
commands in it.

Extract that recognition into a scanner that answers on the same terms
the typed path uses: no-arg commands only, no paths, built-ins as
invocations while skills may also be named mid-prose, and a trailing
token still-typed unless the caller says the text is inert.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 01:39:12 -05:00
parent 382282d5a0
commit c5a68213fa
2 changed files with 150 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { slashCommandMatches } from './slash-refs'
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
describe('slashCommandMatches', () => {
it('recognizes a leading command and a skill named mid-prose', () => {
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
'skill:/some-skill',
'skill:/other-skill'
])
})
it('leaves a path alone — /usr/local/bin is not a command', () => {
expect(commands('see /usr/local/bin ')).toEqual([])
})
it('holds a trailing token as still-typed unless the text is inert', () => {
expect(commands('/some-skill')).toEqual([])
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
})
it('leaves an arg-taking command as text — its tail may be prose', () => {
expect(commands('/goal ship the redesign')).toEqual([])
})
it('leaves a command with no desktop surface as text', () => {
expect(commands('/exit now')).toEqual([])
})
it('offers a built-in only as an invocation, never mid-message', () => {
// Mirrors what the popover offers: `/new` acts on the app, so it means
// nothing dropped into a sentence, while a skill reads as "handle this
// part with X".
expect(commands('/new ')).toEqual(['command:/new'])
expect(commands('start over with /new ')).toEqual([])
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
})
it('disqualifies a leading token when the text lands mid-word', () => {
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
})
})

View file

@ -0,0 +1,105 @@
/**
* Slash-command recognition for text the composer did not watch being typed
* a paste, a restored draft, an undo step, a rebuilt line.
*
* The typed path chips a command as it's picked or accepted, so the composer
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
* directive-text). Text that arrives whole never passed through that path, so
* it needs the same commands recognized in place on exactly the terms the
* typed path would have used, or hydration invents pills the popover would
* never have committed.
*/
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
// A command token starts a word and doesn't continue into a path: `/usr/local`
// is a path, not a `/usr` command. Same shape the sent message uses to decide
// what renders as a pill, so the composer and the transcript agree.
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
export interface SlashCommandMatch {
/** The command with its leading slash, e.g. `/clean`. */
command: string
end: number
kind: SlashChipKind
start: number
}
export interface SlashCommandScanOptions {
/**
* Whether the text is preceded by a token boundary. False when it's being
* inserted mid-word (a paste landing against existing characters), which
* disqualifies a token at index 0 `foo/clean` is not a command. It also
* makes that token mid-message rather than an invocation.
*/
boundaryBefore?: boolean
/**
* Whether a token ending the text counts as committed. True for inert text
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
* is the whole command. False while editing live, where a trailing `/wor` is
* a half-typed query the popover owns and must leave editable.
*/
trailingCommitted?: boolean
}
/**
* Only commands with NO argument stage chip: their committed pill is exactly
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
* (`/goal ship it`) stay text their tail may be prose. Commands with no
* desktop surface at all (`/exit`, `/config`) stay text too.
*/
function chippableKind(command: string): SlashChipKind | null {
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
return null
}
return resolveDesktopCommand(command) ? 'command' : 'skill'
}
/** Every `/command` in `text` that should render as a pill, in source order. */
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
const { boundaryBefore = true, trailingCommitted = false } = options
if (!text.includes('/')) {
return []
}
const matches: SlashCommandMatch[] = []
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
const start = match.index ?? 0
const command = match[0]
const end = start + command.length
const after = text[end]
// A committed pill always carries its auto-inserted trailing space, which
// is what separates it from a token still being typed.
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
continue
}
// Only the FIRST token can be an invocation, and only when the text lands
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
const invocation = start === 0
if (invocation && !boundaryBefore) {
continue
}
const kind = chippableKind(command)
// Later tokens are references dropped into prose, where the popover offers
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
// never would.
if (kind && (invocation || kind === 'skill')) {
matches.push({ command, end, kind, start })
}
}
return matches
}