storkit: merge 438_story_slash_command_autocomplete_in_web_ui_text_input

This commit is contained in:
dave
2026-03-28 22:09:36 +00:00
parent a53967453e
commit 5992f9bd19
10 changed files with 513 additions and 83 deletions
+172 -4
View File
@@ -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}