From c5a68213fad97c8d533c0661c14560c18cadeeb0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:39:12 -0500 Subject: [PATCH 1/3] feat(desktop): recognize slash commands in text the composer didn't watch typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/composer/slash-refs.test.ts | 45 ++++++++ .../src/app/chat/composer/slash-refs.ts | 105 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 apps/desktop/src/app/chat/composer/slash-refs.test.ts create mode 100644 apps/desktop/src/app/chat/composer/slash-refs.ts diff --git a/apps/desktop/src/app/chat/composer/slash-refs.test.ts b/apps/desktop/src/app/chat/composer/slash-refs.test.ts new file mode 100644 index 00000000000..2b50d750875 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/slash-refs.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { slashCommandMatches } from './slash-refs' + +const commands = (text: string, options?: Parameters[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([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/slash-refs.ts b/apps/desktop/src/app/chat/composer/slash-refs.ts new file mode 100644 index 00000000000..db0cf42adb6 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/slash-refs.ts @@ -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 +} From ccca952b92be9cb9b887518e795d4fcef1252174 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:39:20 -0500 Subject: [PATCH 2/3] feat(desktop): chip pasted directives in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appendComposerContents` — the one builder every paste goes through — only ever chipped `@kind:value` refs. Slash commands had a single leading-token special case in `renderComposerContents`, which paste doesn't call, so a pasted `/clean` landed as dead text while the same text typed by hand became a pill. Both directive kinds now hydrate from one ordered span walk, with `@` refs winning a tie so a slash inside a quoted ref value stays part of that value. Paste additionally scans as inert text: a command ending the paste is complete rather than half-typed, and the insertion point's own token boundary decides the leading token, so `foo` + `/clean` stays `foo/clean`. `textBeforeCaret`'s chip-atomic serialization moves to rich-editor as `serializeTextBefore` — the paste path needs the same "a chip edge is a token boundary" reading that trigger detection does. --- .../src/app/chat/composer/rich-editor.ts | 155 +++++++++++++----- .../src/app/chat/composer/text-utils.ts | 20 +-- 2 files changed, 114 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index bc6a1f26bc8..9958c3a4b09 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -16,22 +16,13 @@ import { type SlashChipKind, slashIconElement } from '@/components/assistant-ui/directive-text' -import { - desktopSlashCommandArgumentMode, - isDesktopSlashCommand, - resolveDesktopCommand -} from '@/lib/desktop-slash-commands' + +import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g -/** A committed leading slash command: `/name` followed by whitespace. The - * whitespace requirement is what separates a committed command (chips always - * serialize with their auto-inserted trailing space) from one still being - * typed, which must stay editable text. */ -const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/ - const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } export function escapeHtml(value: string) { @@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri }) } -export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) { - let cursor = 0 - +/** Every span of `text` that renders as a chip, in source order. */ +function chipSpans(text: string, options: SlashCommandScanOptions) { REF_RE.lastIndex = 0 - for (const match of text.matchAll(REF_RE)) { - const index = match.index ?? 0 - appendTextWithBreaks(target, text.slice(cursor, index)) - target.append(refChipElement(match[1] || 'file', match[2] || '')) - cursor = index + match[0].length + const refs = Array.from(text.matchAll(REF_RE)).map(match => { + const start = match.index ?? 0 + + return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start } + }) + + const commands = slashCommandMatches(text, options).map(match => ({ + end: match.end, + node: () => slashChipElement(match.command, match.kind), + start: match.start + })) + + return [...refs, ...commands].sort((a, b) => a.start - b.start) +} + +/** Build the chip/text DOM for `text`. Directives hydrate back to their pills — + * `@kind:value` refs and `/command` invocations both — so text that arrives + * whole (a paste, a restored draft, an undo step, a rebuilt line) carries the + * same chips the typed path would have committed. */ +export function appendComposerContents( + target: DocumentFragment | HTMLElement, + text: string, + options: SlashCommandScanOptions = {} +) { + let cursor = 0 + + for (const span of chipSpans(text, options)) { + // A `@` ref wins an overlap: a command token can't contain an `@`, so the + // only way spans collide is a slash inside a quoted ref value + // (`` @url:`a /clean` ``), which belongs to that value. + if (span.start < cursor) { + continue + } + + appendTextWithBreaks(target, text.slice(cursor, span.start)) + target.append(span.node()) + cursor = span.end } appendTextWithBreaks(target, text.slice(cursor)) } -export function renderComposerContents(target: HTMLElement, text: string) { +export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) { target.replaceChildren() - // A leading `/command` hydrates back to its pill — parity with REF_RE for - // `@` refs, so a full re-render from serialized text (draft restore, undo, - // the trigger commit fallback) doesn't demote a committed command chip to - // plain text. Only commands with NO argument stage qualify (skills, quick - // commands, no-arg built-ins): their committed pill is exactly the bare - // `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship - // it`, `/personality alice`) stay text — their tail may be prose that was - // never committed. The trailing whitespace is load-bearing too: a committed - // pill always serializes with its auto-inserted space, while a half-typed - // `/wor` must stay editable text. - const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0] - - if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) { - target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill')) - text = text.slice(command.length) - } - - appendComposerContents(target, text) + // Defaults to live editing, where a token ending the text is still being + // typed (`/wor`) and must stay editable. Callers repainting inert text (a + // restored draft, a sent message opened for edit) pass `trailingCommitted`. + appendComposerContents(target, text, options) } /** Caret range when the selection lives inside `editor`; else null. */ @@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) { return { range, selection } } -/** Insert text at the caret (replacing any selection), with any `@kind:value` - * directives in it landing as chips. Pastes use this instead of - * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large - * multiline blobs. */ +/** Serialized text from the editor's start up to (`container`, `offset`). + * + * Chips are ATOMIC here: each contributes an object-replacement placeholder + * rather than leaking its label text, and a
contributes a newline. That + * makes a chip edge read as a token boundary, which is what both trigger + * detection and directive recognition need. */ +export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string { + const probe = document.createRange() + + probe.selectNodeContents(editor) + probe.setEnd(container, offset) + + const scratch = document.createElement('div') + + scratch.append(probe.cloneContents()) + + for (const chip of scratch.querySelectorAll('[data-ref-text]')) { + chip.replaceWith('\uFFFC') + } + + for (const br of scratch.querySelectorAll('br')) { + br.replaceWith('\n') + } + + return scratch.textContent ?? '' +} + +/** True when the insertion point starts a token — the editor's start, or after + * whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a + * command; `foo ` + the same paste is. */ +function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean { + // No caret means the insert lands at the end, so the question is about the + // editor's last character either way. + const before = range + ? serializeTextBefore(editor, range.startContainer, range.startOffset) + : serializeTextBefore(editor, editor, editor.childNodes.length) + + const last = before.slice(-1) + + return !last || /[\s\uFFFC]/.test(last) +} + +/** Insert text at the caret (replacing any selection), with any directives in + * it landing as chips. Pastes use this instead of `execCommand('insertText')` + * — Chromium's editing pipeline is ~O(n²) on large multiline blobs. + * + * The text arrives whole rather than typed, so a `/command` ending it is + * complete rather than half-written and chips like the rest. */ export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) { const hit = composerSelectionRange(editor) const fragment = document.createDocumentFragment() - appendComposerContents(fragment, text) + // Before measuring the boundary — a replaced selection puts the insertion + // point where the selection started, not where it ended. + if (hit) { + hit.range.deleteContents() + } + + appendComposerContents(fragment, text, { + boundaryBefore: atTokenBoundary(editor, hit?.range ?? null), + trailingCommitted: true + }) + + // A slash pill ending the insert gets the trailing space the typed commit + // path appends, or the next full re-render reads it as a half-typed token + // and demotes it. `@` refs need no marker — REF_RE re-chips them either way. + if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) { + fragment.append(document.createTextNode(' ')) + } const tail = fragment.lastChild if (hit) { - hit.range.deleteContents() hit.range.insertNode(fragment) } else { editor.append(fragment) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 19dfa8ce0d9..3224716b27c 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -1,6 +1,8 @@ import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' import { $reactionsEnabled } from '@/store/reactions-enabled' +import { serializeTextBefore } from './rich-editor' + export interface TriggerState { /** True for a `/` typed mid-message — an inline skill/command reference in * prose rather than a command invocation. Arg completion doesn't apply. */ @@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { return null } - const before = range.cloneRange() - before.selectNodeContents(editor) - before.setEnd(range.startContainer, range.startOffset) - - const scratch = document.createElement('div') - - scratch.append(before.cloneContents()) - - for (const chip of scratch.querySelectorAll('[data-ref-text]')) { - chip.replaceWith('\uFFFC') - } - - for (const br of scratch.querySelectorAll('br')) { - br.replaceWith('\n') - } - - return scratch.textContent ?? '' + return serializeTextBefore(editor, range.startContainer, range.startOffset) } export function detectTrigger(textBefore: string): TriggerState | null { From 422ecfe1da8376df1dac88fc523c21675d827830 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 01:39:29 -0500 Subject: [PATCH 3/3] feat(desktop): hydrate commands when repainting inert composer text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two repaint sites hand the editor text that is finished rather than mid-keystroke: the main composer's programmatic draft writes (restore, insert, history recall) and the inline edit composer opening a sent message. Both now render with `trailingCommitted`, so a command ending that text chips instead of reading as a half-typed token — the edit composer in particular showed plain text for a message the transcript had just rendered with a pill. Regression tests cover the paste path: a command ending the paste, one named mid-prose beside a ref, a path left alone, a paste landing against a word, and one landing after an existing chip. --- .../chat/composer/hooks/use-composer-draft.ts | 4 +- .../src/app/chat/composer/rich-editor.test.ts | 76 +++++++++++++++++++ .../thread/user-edit-composer.tsx | 8 +- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index d981d6435d8..82464ffd5d4 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -121,7 +121,7 @@ export function useComposerDraft({ const editor = editorRef.current if (editor) { - renderComposerContents(editor, next) + renderComposerContents(editor, next, { trailingCommitted: true }) placeCaretEnd(editor) } @@ -265,7 +265,7 @@ export function useComposerDraft({ const editor = editorRef.current if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) { - renderComposerContents(editor, text) + renderComposerContents(editor, text, { trailingCommitted: true }) } if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index e55f24e8cf1..6c3d2f87332 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => { editor.remove() }) + + // A directive typed by hand chips; the same directive pasted has to chip too, + // or copy/pasting a prompt silently drops every command in it. + it('chips a pasted slash command, including one that ends the paste', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, '/some-skill') + + expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill') + // Committed pills carry the trailing space the typed path appends, so a + // later full re-render doesn't read the token as half-typed. + expect(composerPlainText(editor)).toBe('/some-skill ') + + editor.remove() + }) + + it('chips a skill named mid-paste alongside a ref', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship') + + expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1) + expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1) + expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship') + + editor.remove() + }) + + it('leaves a pasted path alone — /usr/local is not a command', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it') + + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it') + + editor.remove() + }) + + it('does not chip a command pasted against a word — foo/clean is not a command', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'foo' + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, '/some-skill') + + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(composerPlainText(editor)).toBe('foo/some-skill') + + editor.remove() + }) + + it('chips a command pasted right after an existing chip', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(refChipElement('file', '`a.ts`')) + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, '/some-skill') + + expect(editor.querySelector('[data-slash-kind]')).not.toBeNull() + + editor.remove() + }) }) describe('replaceBeforeCaret', () => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index f27c5358af6..fe7b917afbe 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -168,7 +168,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const editor = editorRef.current if (editor) { - renderComposerContents(editor, next) + renderComposerContents(editor, next, { trailingCommitted: true }) placeCaretEnd(editor) } @@ -187,7 +187,11 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess editor && (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft)) ) { - renderComposerContents(editor, draft) + // Inert by construction — this repaints on mount or when the editor + // isn't the one being typed into. A message opened for edit is finished + // text, so a `/command` ending it is committed and chips, matching how + // the transcript rendered that same message a moment ago. + renderComposerContents(editor, draft, { trailingCommitted: true }) if (document.activeElement === editor) { placeCaretEnd(editor)