diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 3881493da350..2921b1d3a538 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3570,6 +3570,138 @@ describe("chat slash menu accessibility", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("executes a selected inline command argument and preserves the surrounding draft", () => { + 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 /verb"); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).not.toHaveBeenCalled(); + expect(draft).toBe("hello /verb"); + const fullOption = Array.from(container.querySelectorAll(".slash-menu-item")).find( + (item) => item.querySelector(".slash-menu-name")?.textContent?.trim() === "full", + ); + expect(fullOption).toBeInstanceOf(HTMLElement); + fullOption?.click(); + + expect(onSlashCommand).toHaveBeenCalledExactlyOnceWith("/verbose full"); + expect(draft).toBe("hello "); + expect(container.querySelector("textarea")?.value).toBe(draft); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("executes a typed inline command argument and preserves the surrounding draft", () => { + 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 /thin"); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).not.toHaveBeenCalled(); + expect(draft).toBe("hello /think "); + + inputDraftAtEnd(container, "hello /think high"); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).toHaveBeenCalledExactlyOnceWith("/think high"); + 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; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSend = vi.fn(); + const onSlashCommand = vi.fn(); + const onSlashIntent = vi.fn( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + const { container } = createReactiveDraftHarness({ + onDraftChange, + onSend, + onSlashCommand, + onSlashIntent, + }); + + inputDraftAtEnd(container, "hello /thin"); + keydownComposer(container, "Enter"); + expect(draft).toBe("hello /think "); + + resolveRefresh?.(); + await Promise.resolve(); + await Promise.resolve(); + inputDraftAtEnd(container, "hello /think high"); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).toHaveBeenCalledExactlyOnceWith("/think high"); + expect(draft).toBe("hello "); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("removes a typed inline command argument without consuming trailing prose", () => { + let draft = ""; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSlashCommand = vi.fn(); + const { container } = createReactiveDraftHarness({ onDraftChange, onSlashCommand }); + const textarea = getComposerTextarea(container); + const initial = "before /thin after"; + const commandEnd = initial.indexOf("/thin") + "/thin".length; + textarea.value = initial; + textarea.setSelectionRange(commandEnd, commandEnd); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + + keydownComposer(container, "Enter"); + expect(draft).toBe("before /think after"); + + const withArgument = "before /think high after"; + const argumentEnd = withArgument.indexOf("high") + "high".length; + textarea.value = withArgument; + textarea.setSelectionRange(argumentEnd, argumentEnd); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + keydownComposer(container, "Enter"); + + expect(onSlashCommand).toHaveBeenCalledExactlyOnceWith("/think high"); + expect(draft).toBe("before after"); + expect(textarea.value).toBe(draft); + }); + + it("tab-completes an inline command argument without replacing surrounding prose", () => { + let draft = ""; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSlashCommand = vi.fn(); + const { container } = createReactiveDraftHarness({ onDraftChange, onSlashCommand }); + + inputDraftAtEnd(container, "hello /verb"); + keydownComposer(container, "Tab"); + keydownComposer(container, "Tab"); + + expect(onSlashCommand).not.toHaveBeenCalled(); + expect(draft).toBe("hello /verbose on "); + expect(container.querySelector("textarea")?.value).toBe(draft); + }); + it("keeps inline skill selection in the draft for the eventual model turn", () => { replaceSkillCommands({ key: "weather", description: "Check the weather." }); let draft = ""; 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 cd21c4663e25..f97b5cc35956 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -60,6 +60,13 @@ function requestSlashCommandRefresh( void Promise.resolve(refresh).finally(() => { state.slashCommandRefreshPending = false; const nextValue = getCurrentValue?.() ?? props.getDraft?.() ?? value; + if (state.slashMenuMode === "freeform-args" && state.slashMenuCompletion?.inline) { + updateSlashMenu(nextValue, requestUpdate, props, { skipSlashIntent: true }); + return; + } + if (state.slashMenuMode === "args" && state.slashMenuCompletion?.inline) { + return; + } const caret = state.composerTextarea?.selectionStart ?? nextValue.length; if (!findInlineSlashCompletion(nextValue, caret)) { closeSlashMenuIfNeeded(state, requestUpdate); @@ -77,6 +84,21 @@ export function updateSlashMenu( getCurrentValue?: () => string, ): void { const state = getChatComposerState(props.paneId); + if ( + state.slashMenuMode === "freeform-args" && + state.slashMenuCompletion?.inline && + state.slashMenuCommand + ) { + const caret = state.composerTextarea?.selectionStart ?? value.length; + const prefix = `/${state.slashMenuCommand.name} `; + const start = state.slashMenuCompletion.start; + if (caret >= start + prefix.length && value.slice(start, start + prefix.length) === prefix) { + state.slashMenuCompletion.end = caret; + requestUpdate(); + return; + } + resetSlashMenuState(state); + } const argMatch = value.match(/^\/(\S+)\s(.*)$/); if (argMatch) { if (!opts.skipSlashIntent) { @@ -164,6 +186,77 @@ function commitInlineSlashSelection( return true; } +function beginInlineFreeformSlashArguments( + cmd: SlashCommandDef, + props: ChatComposerProps, + state: ChatComposerState, +): boolean { + const completion = state.slashMenuCompletion; + if (!completion?.inline) { + return false; + } + const target = state.composerTextarea; + const current = target?.value ?? props.getDraft?.() ?? props.draft; + const replacement = `/${cmd.name} `; + const next = `${current.slice(0, completion.start)}${replacement}${current.slice(completion.end)}`; + const caret = completion.start + replacement.length; + if (target) { + target.value = next; + adjustTextareaHeight(target); + } + commitComposerDraft(props, next); + state.slashMenuCompletion = { + query: cmd.name, + start: completion.start, + end: caret, + inline: true, + }; + queueMicrotask(() => { + const textarea = state.composerTextarea; + if (!textarea) { + return; + } + textarea.focus({ preventScroll: true }); + textarea.selectionStart = caret; + textarea.selectionEnd = caret; + }); + return true; +} + +function beginInlineSlashArguments( + cmd: SlashCommandDef, + props: ChatComposerProps, + state: ChatComposerState, + requestUpdate: () => void, +): boolean { + if ( + !state.slashMenuCompletion?.inline || + cmd.source === "skill" || + !cmd.args || + !props.onSlashCommand + ) { + return false; + } + state.slashMenuCommand = cmd; + state.slashMenuIndex = 0; + state.slashMenuItems = []; + if (cmd.argOptions?.length) { + state.slashMenuMode = "args"; + state.slashMenuArgItems = cmd.argOptions; + state.slashMenuOpen = true; + requestUpdate(); + return true; + } + if (!beginInlineFreeformSlashArguments(cmd, props, state)) { + return false; + } + state.slashMenuMode = "freeform-args"; + state.slashMenuArgItems = []; + state.slashMenuOpen = false; + requestUpdate(); + return true; +} + function removeInlineSlashSelection(props: ChatComposerProps, state: ChatComposerState): boolean { const completion = state.slashMenuCompletion; if (!completion?.inline) { @@ -203,6 +296,9 @@ export function selectSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); + if (beginInlineSlashArguments(cmd, props, state, requestUpdate)) { + return; + } if ( state.slashMenuCompletion?.inline && cmd.source !== "skill" && @@ -250,6 +346,9 @@ export function tabCompleteSlashCommand( requestUpdate: () => void, ) { const state = getChatComposerState(props.paneId); + if (beginInlineSlashArguments(cmd, props, state, requestUpdate)) { + return; + } if (commitInlineSlashSelection(`/${cmd.name}`, props, state)) { state.slashMenuOpen = false; resetSlashMenuState(state); @@ -280,7 +379,31 @@ export function selectSlashArg( run: boolean, ) { const state = getChatComposerState(props.paneId); - const cmdName = state.slashMenuCommand?.name ?? ""; + const command = state.slashMenuCommand; + const cmdName = command?.name ?? ""; + if ( + run && + state.slashMenuCompletion?.inline && + command?.source !== "skill" && + props.onSlashCommand && + removeInlineSlashSelection(props, state) + ) { + state.slashMenuOpen = false; + resetSlashMenuState(state); + requestUpdate(); + props.onSlashCommand(`/${cmdName} ${arg}`); + return; + } + if ( + !run && + state.slashMenuCompletion?.inline && + commitInlineSlashSelection(`/${cmdName} ${arg}`, props, state) + ) { + state.slashMenuOpen = false; + resetSlashMenuState(state); + requestUpdate(); + return; + } state.slashMenuOpen = false; resetSlashMenuState(state); commitComposerDraft(props, `/${cmdName} ${arg}`); @@ -290,6 +413,54 @@ export function selectSlashArg( requestUpdate(); } +function submitInlineSlashArgument(props: ChatComposerProps, requestUpdate: () => void): boolean { + const state = getChatComposerState(props.paneId); + const command = state.slashMenuCommand; + const completion = state.slashMenuCompletion; + if ( + state.slashMenuMode !== "freeform-args" || + !completion?.inline || + !command || + !props.onSlashCommand + ) { + return false; + } + const target = state.composerTextarea; + const current = target?.value ?? props.getDraft?.() ?? props.draft; + const prefixEnd = completion.start + `/${command.name} `.length; + const args = current.slice(prefixEnd, completion.end).trim(); + if (!removeInlineSlashSelection(props, state)) { + return false; + } + state.slashMenuOpen = false; + resetSlashMenuState(state); + requestUpdate(); + props.onSlashCommand(`/${command.name}${args ? ` ${args}` : ""}`); + 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") { + event.preventDefault(); + resetSlashMenuState(state); + requestUpdate(); + return true; + } + if (event.key !== "Enter") { + return false; + } + event.preventDefault(); + return submitInlineSlashArgument(props, requestUpdate); +} + function slashOptionIdSegment(value: string): string { return ( value diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 89e7c46fbb88..649a5c16dc6a 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -162,7 +162,7 @@ export type ChatComposerState = { slashMenuOpen: boolean; slashMenuItems: SlashCommandDef[]; slashMenuIndex: number; - slashMenuMode: "command" | "args"; + slashMenuMode: "command" | "args" | "freeform-args"; slashMenuCommand: SlashCommandDef | null; slashMenuArgItems: string[]; slashCommandRefreshPending: boolean; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index e9520a7eafaa..e9311af023e4 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -31,6 +31,7 @@ import { exportMarkdown, getActiveSlashMenuOptionId, getActiveSlashMenuOptionLabel, + handleInlineSlashArgumentKeyDown, isSlashMenuVisible, paneDomId, resetSlashMenuState, @@ -331,6 +332,10 @@ export function renderChatComposer(props: ChatComposerProps) { } } + if (props.connected && handleInlineSlashArgumentKeyDown(event, props, requestUpdate)) { + return; + } + if ( props.connected && state.slashMenuOpen &&