feat(ui): support inline slash command arguments

This commit is contained in:
Jesse Merhi
2026-08-13 00:24:21 +10:00
parent c4db6242a0
commit ffcd4c08d4
4 changed files with 310 additions and 2 deletions
+132
View File
@@ -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<HTMLElement>(".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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<void>((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<HTMLTextAreaElement>("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 = "";
@@ -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
@@ -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;
@@ -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 &&