From ade6fb3517a8de32094c474360539769ea363003 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:36:58 +1000 Subject: [PATCH] fix(ui): execute directly typed inline command args --- ui/src/pages/chat/chat-view.test.ts | 28 ++++++++++ .../components/chat-composer-inline-slash.ts | 55 +++++++++++++++++++ .../components/chat-composer-slash-menu.ts | 33 ++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 ui/src/pages/chat/components/chat-composer-inline-slash.ts diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 2921b1d3a538..d36d1f63d7ad 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3620,6 +3620,34 @@ describe("chat slash menu accessibility", () => { expect(onSend).not.toHaveBeenCalled(); }); + it.each([ + ["think", "high"], + ["verbose", "full"], + ])( + "executes a directly typed inline /%s argument without requiring completion", + (command, argument) => { + let draft = ""; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSend = vi.fn(); + const onSlashCommand = vi.fn(); + const { container } = createReactiveDraftHarness({ + onDraftChange, + onSend, + onSlashCommand, + }); + + inputDraftAtEnd(container, `hello /${command} ${argument}`); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).toHaveBeenCalledExactlyOnceWith(`/${command} ${argument}`); + expect(draft).toBe("hello "); + expect(container.querySelector("textarea")?.value).toBe(draft); + expect(onSend).not.toHaveBeenCalled(); + }, + ); + it("preserves typed inline argument mode across command hydration", async () => { let draft = ""; let resolveRefresh: (() => void) | undefined; diff --git a/ui/src/pages/chat/components/chat-composer-inline-slash.ts b/ui/src/pages/chat/components/chat-composer-inline-slash.ts new file mode 100644 index 000000000000..96bc942b7abc --- /dev/null +++ b/ui/src/pages/chat/components/chat-composer-inline-slash.ts @@ -0,0 +1,55 @@ +import { + getSlashCommandCompletions, + type InlineSlashCompletion, + type SlashCommandDef, +} from "../../../lib/chat/commands.ts"; + +export type InlineSlashArgumentInvocation = { + command: SlashCommandDef; + completion: InlineSlashCompletion; +}; + +export function findDirectInlineSlashArgumentInvocation( + text: string, + caret = text.length, +): InlineSlashArgumentInvocation | null { + const boundedCaret = Math.max(0, Math.min(caret, text.length)); + const prefix = text.slice(0, boundedCaret); + const commandPattern = /(?:^|\s)\/([^\s/:]+)\s+/gu; + let invocation: InlineSlashArgumentInvocation | null = null; + + for (const match of prefix.matchAll(commandPattern)) { + const typedName = match[1]?.toLowerCase(); + if (!typedName || match.index === undefined) { + continue; + } + const command = getSlashCommandCompletions(typedName, { + showAll: true, + inlineOnly: true, + }).find( + (entry) => + entry.name.toLowerCase() === typedName || + entry.aliases?.some((alias) => alias.replace(/^\//u, "").toLowerCase() === typedName), + ); + if (!command?.args || command.source === "skill") { + continue; + } + const start = match.index + match[0].indexOf("/"); + const args = prefix.slice(match.index + match[0].length).trim(); + if (!args) { + continue; + } + invocation = { + command, + completion: { + query: command.name, + start, + end: boundedCaret, + inline: + text.slice(0, start).trim().length > 0 || text.slice(boundedCaret).trim().length > 0, + }, + }; + } + + return invocation?.completion.inline ? invocation : null; +} diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index f97b5cc35956..a4786df62f00 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -12,6 +12,7 @@ import { } from "../../../lib/chat/commands.ts"; import { exportChatMarkdown } from "../export.ts"; import { adjustTextareaHeight } from "./chat-composer-dom.ts"; +import { findDirectInlineSlashArgumentInvocation } from "./chat-composer-inline-slash.ts"; import { commitComposerDraft, getChatComposerState } from "./chat-composer-state.ts"; import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts"; @@ -439,16 +440,36 @@ function submitInlineSlashArgument(props: ChatComposerProps, requestUpdate: () = return true; } +function beginDirectInlineSlashArgument( + props: ChatComposerProps, + state: ChatComposerState, +): boolean { + if (!props.onSlashCommand) { + return false; + } + const target = state.composerTextarea; + const current = target?.value ?? props.getDraft?.() ?? props.draft; + const caret = target?.selectionStart ?? current.length; + const invocation = findDirectInlineSlashArgumentInvocation(current, caret); + if (!invocation) { + return false; + } + state.slashMenuMode = "freeform-args"; + state.slashMenuCommand = invocation.command; + state.slashMenuCompletion = invocation.completion; + return true; +} + export function handleInlineSlashArgumentKeyDown( event: KeyboardEvent, props: ChatComposerProps, requestUpdate: () => void, ): boolean { const state = getChatComposerState(props.paneId); - if (state.slashMenuMode !== "freeform-args" || !state.slashMenuCompletion?.inline) { - return false; - } if (event.key === "Escape") { + if (state.slashMenuMode !== "freeform-args" || !state.slashMenuCompletion?.inline) { + return false; + } event.preventDefault(); resetSlashMenuState(state); requestUpdate(); @@ -457,6 +478,12 @@ export function handleInlineSlashArgumentKeyDown( if (event.key !== "Enter") { return false; } + if ( + (state.slashMenuMode !== "freeform-args" || !state.slashMenuCompletion?.inline) && + !beginDirectInlineSlashArgument(props, state) + ) { + return false; + } event.preventDefault(); return submitInlineSlashArgument(props, requestUpdate); }