mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(ui): execute directly typed inline command args
This commit is contained in:
@@ -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<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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user