storkit: merge 438_story_slash_command_autocomplete_in_web_ui_text_input
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { api } from "../api/client";
|
||||
import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands";
|
||||
|
||||
const {
|
||||
forwardRef,
|
||||
@@ -113,6 +114,83 @@ function FilePickerOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
interface SlashCommandPickerOverlayProps {
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (cmd: SlashCommand) => void;
|
||||
}
|
||||
|
||||
function SlashCommandPickerOverlay({
|
||||
query,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: SlashCommandPickerOverlayProps) {
|
||||
const filtered = SLASH_COMMANDS.filter((cmd) =>
|
||||
fuzzyMatch(cmd.name, query),
|
||||
).sort((a, b) => fuzzyScore(a.name, query) - fuzzyScore(b.name, query));
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="slash-command-picker"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "6px",
|
||||
overflow: "hidden",
|
||||
zIndex: 100,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{filtered.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
data-testid={`slash-command-item-${idx}`}
|
||||
onClick={() => onSelect(cmd)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "10px 14px",
|
||||
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
<code
|
||||
style={{
|
||||
fontSize: "0.88rem",
|
||||
color: idx === selectedIndex ? "#ececec" : "#e0e0e0",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{cmd.name}
|
||||
</code>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: idx === selectedIndex ? "#b0c0d0" : "#888",
|
||||
}}
|
||||
>
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
function ChatInput(
|
||||
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
||||
@@ -127,6 +205,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
||||
const [pickerAtStart, setPickerAtStart] = useState(0);
|
||||
|
||||
// Slash command picker state
|
||||
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendToInput(text: string) {
|
||||
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
@@ -153,6 +235,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
setPickerSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
// Compute filtered slash commands for current query
|
||||
const filteredCommands =
|
||||
slashQuery !== null
|
||||
? SLASH_COMMANDS.filter((cmd) => fuzzyMatch(cmd.name, slashQuery)).sort(
|
||||
(a, b) =>
|
||||
fuzzyScore(a.name, slashQuery) - fuzzyScore(b.name, slashQuery),
|
||||
)
|
||||
: [];
|
||||
|
||||
const dismissSlashPicker = useCallback(() => {
|
||||
setSlashQuery(null);
|
||||
setSlashSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
const selectCommand = useCallback(
|
||||
(cmd: SlashCommand) => {
|
||||
// Extract base command (first word, e.g. "/assign" from "/assign <number> <model>")
|
||||
const baseCommand = cmd.name.split(" ")[0];
|
||||
setInput(`${baseCommand} `);
|
||||
dismissSlashPicker();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
},
|
||||
[dismissSlashPicker],
|
||||
);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(file: string) => {
|
||||
// Replace the @query portion with @file
|
||||
@@ -173,11 +280,20 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
setInput(val);
|
||||
|
||||
const cursor = e.target.selectionStart ?? val.length;
|
||||
// Find the last @ before the cursor that starts a reference token
|
||||
const textUpToCursor = val.slice(0, cursor);
|
||||
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
|
||||
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||
|
||||
// Slash command picker: triggered when input starts with / and no space yet
|
||||
const slashMatch = textUpToCursor.match(/^\/(\S*)$/);
|
||||
if (slashMatch) {
|
||||
setSlashQuery(slashMatch[1]);
|
||||
setSlashSelectedIndex(0);
|
||||
if (pickerQuery !== null) dismissPicker();
|
||||
return;
|
||||
}
|
||||
if (slashQuery !== null) dismissSlashPicker();
|
||||
|
||||
// File picker: triggered by @ at start or after whitespace
|
||||
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||
if (atMatch) {
|
||||
const query = atMatch[2];
|
||||
const atPos = textUpToCursor.lastIndexOf("@");
|
||||
@@ -196,11 +312,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
if (pickerQuery !== null) dismissPicker();
|
||||
}
|
||||
},
|
||||
[projectFiles.length, pickerQuery, dismissPicker],
|
||||
[
|
||||
projectFiles.length,
|
||||
pickerQuery,
|
||||
dismissPicker,
|
||||
slashQuery,
|
||||
dismissSlashPicker,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Slash command picker navigation
|
||||
if (slashQuery !== null && filteredCommands.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSlashSelectedIndex((i) =>
|
||||
Math.min(i + 1, filteredCommands.length - 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSlashSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectCommand(
|
||||
filteredCommands[slashSelectedIndex] ?? filteredCommands[0],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
dismissSlashPicker();
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "Escape" && slashQuery !== null) {
|
||||
e.preventDefault();
|
||||
dismissSlashPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
// File picker navigation
|
||||
if (pickerQuery !== null && filteredFiles.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -236,6 +391,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
}
|
||||
},
|
||||
[
|
||||
slashQuery,
|
||||
filteredCommands,
|
||||
slashSelectedIndex,
|
||||
selectCommand,
|
||||
dismissSlashPicker,
|
||||
pickerQuery,
|
||||
filteredFiles,
|
||||
pickerSelectedIndex,
|
||||
@@ -249,6 +409,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
onSubmit(input);
|
||||
setInput("");
|
||||
dismissPicker();
|
||||
dismissSlashPicker();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -357,6 +518,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{slashQuery !== null && (
|
||||
<SlashCommandPickerOverlay
|
||||
query={slashQuery}
|
||||
selectedIndex={slashSelectedIndex}
|
||||
onSelect={selectCommand}
|
||||
/>
|
||||
)}
|
||||
{pickerQuery !== null && (
|
||||
<FilePickerOverlay
|
||||
query={pickerQuery}
|
||||
|
||||
Reference in New Issue
Block a user