storkit: merge 438_story_slash_command_autocomplete_in_web_ui_text_input
This commit is contained in:
@@ -1481,6 +1481,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/status" } });
|
fireEvent.change(input, { target: { value: "/status" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1551,6 +1555,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/git" } });
|
fireEvent.change(input, { target: { value: "/git" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1569,6 +1577,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/cost" } });
|
fireEvent.change(input, { target: { value: "/cost" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1595,6 +1607,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/reset" } });
|
fireEvent.change(input, { target: { value: "/reset" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1634,6 +1650,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/help" } });
|
fireEvent.change(input, { target: { value: "/help" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1652,6 +1672,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/git" } });
|
fireEvent.change(input, { target: { value: "/git" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
)}
|
)}
|
||||||
{messages.map((msg: Message, idx: number) => (
|
{messages.map((msg: Message, idx: number) => (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
|
||||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forwardRef,
|
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>(
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
function ChatInput(
|
function ChatInput(
|
||||||
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
||||||
@@ -127,6 +205,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
||||||
const [pickerAtStart, setPickerAtStart] = 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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
appendToInput(text: string) {
|
appendToInput(text: string) {
|
||||||
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
||||||
@@ -153,6 +235,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
setPickerSelectedIndex(0);
|
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(
|
const selectFile = useCallback(
|
||||||
(file: string) => {
|
(file: string) => {
|
||||||
// Replace the @query portion with @file
|
// Replace the @query portion with @file
|
||||||
@@ -173,11 +280,20 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
setInput(val);
|
setInput(val);
|
||||||
|
|
||||||
const cursor = e.target.selectionStart ?? val.length;
|
const cursor = e.target.selectionStart ?? val.length;
|
||||||
// Find the last @ before the cursor that starts a reference token
|
|
||||||
const textUpToCursor = val.slice(0, cursor);
|
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) {
|
if (atMatch) {
|
||||||
const query = atMatch[2];
|
const query = atMatch[2];
|
||||||
const atPos = textUpToCursor.lastIndexOf("@");
|
const atPos = textUpToCursor.lastIndexOf("@");
|
||||||
@@ -196,11 +312,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
if (pickerQuery !== null) dismissPicker();
|
if (pickerQuery !== null) dismissPicker();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectFiles.length, pickerQuery, dismissPicker],
|
[
|
||||||
|
projectFiles.length,
|
||||||
|
pickerQuery,
|
||||||
|
dismissPicker,
|
||||||
|
slashQuery,
|
||||||
|
dismissSlashPicker,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(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 (pickerQuery !== null && filteredFiles.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -236,6 +391,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
slashQuery,
|
||||||
|
filteredCommands,
|
||||||
|
slashSelectedIndex,
|
||||||
|
selectCommand,
|
||||||
|
dismissSlashPicker,
|
||||||
pickerQuery,
|
pickerQuery,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
pickerSelectedIndex,
|
pickerSelectedIndex,
|
||||||
@@ -249,6 +409,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
onSubmit(input);
|
onSubmit(input);
|
||||||
setInput("");
|
setInput("");
|
||||||
dismissPicker();
|
dismissPicker();
|
||||||
|
dismissSlashPicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -357,6 +518,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{slashQuery !== null && (
|
||||||
|
<SlashCommandPickerOverlay
|
||||||
|
query={slashQuery}
|
||||||
|
selectedIndex={slashSelectedIndex}
|
||||||
|
onSelect={selectCommand}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pickerQuery !== null && (
|
{pickerQuery !== null && (
|
||||||
<FilePickerOverlay
|
<FilePickerOverlay
|
||||||
query={pickerQuery}
|
query={pickerQuery}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
api: {
|
||||||
|
listProjectFiles: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
loading: false,
|
||||||
|
queuedMessages: [],
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onRemoveQueuedMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command picker overlay (Story 438 AC1)", () => {
|
||||||
|
it("shows slash command picker when / is typed at position 0", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show slash command picker for plain text", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show slash command picker when / is not at position 0", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello /world" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command list (Story 438 AC2)", () => {
|
||||||
|
it("lists slash commands with name and description", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
// First command should be /help
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||||||
|
"/help",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command fuzzy filter (Story 438 AC3)", () => {
|
||||||
|
it("filters commands when typing after /", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
// /help should match "hel"
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||||||
|
"/help",
|
||||||
|
);
|
||||||
|
// /rebuild should not be visible (no match for "hel")
|
||||||
|
const items = screen.queryAllByTestId(/^slash-command-item-/);
|
||||||
|
const texts = items.map((el) => el.textContent ?? "");
|
||||||
|
expect(texts.some((t) => t.includes("/rebuild"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no picker when query matches nothing", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/zzzzz" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command keyboard navigation (Story 438 AC4)", () => {
|
||||||
|
it("ArrowDown navigates to next item", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||||||
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item1 = screen.getByTestId("slash-command-item-1");
|
||||||
|
expect(item1).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowUp stays at 0 when already at top", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||||||
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter selects the highlighted command and inserts it", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Tab selects the highlighted command and inserts it", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Tab" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape dismisses the picker", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command selection inserts with trailing space (Story 438 AC5)", () => {
|
||||||
|
it("clicking a command inserts /<command> with trailing space", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("slash-command-item-0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
const val = (textarea as HTMLTextAreaElement).value;
|
||||||
|
expect(val).toMatch(/^\/\w+ $/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selection inserts only the base command (no argument placeholders)", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/ass" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/assign ");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,75 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { SLASH_COMMANDS } from "../slashCommands";
|
||||||
|
|
||||||
const { useEffect, useRef } = React;
|
const { useEffect, useRef } = React;
|
||||||
|
|
||||||
interface SlashCommand {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLASH_COMMANDS: SlashCommand[] = [
|
|
||||||
{
|
|
||||||
name: "/help",
|
|
||||||
description: "Show this list of available slash commands.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/status",
|
|
||||||
description:
|
|
||||||
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/assign <number> <model>",
|
|
||||||
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/start <number>",
|
|
||||||
description:
|
|
||||||
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/show <number>",
|
|
||||||
description: "Display the full text of a work item.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/move <number> <stage>",
|
|
||||||
description:
|
|
||||||
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/delete <number>",
|
|
||||||
description:
|
|
||||||
"Remove a work item from the pipeline and stop any running agent.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/cost",
|
|
||||||
description:
|
|
||||||
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/git",
|
|
||||||
description:
|
|
||||||
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/overview <number>",
|
|
||||||
description: "Show the implementation summary for a merged story.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/rebuild",
|
|
||||||
description: "Rebuild the server binary and restart.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/reset",
|
|
||||||
description:
|
|
||||||
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "/btw <question>",
|
|
||||||
description:
|
|
||||||
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface HelpOverlayProps {
|
interface HelpOverlayProps {
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function MessageItemInner({ msg }: MessageItemProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: ToolCall has no stable ID
|
||||||
key={`tool-${i}-${tc.function.name}`}
|
key={`tool-${i}-${tc.function.name}`}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
filteredLogs.map((entry, idx) => (
|
filteredLogs.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no stable ID
|
||||||
key={`${entry.timestamp}-${idx}`}
|
key={`${entry.timestamp}-${idx}`}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: "/help",
|
||||||
|
description: "Show this list of available slash commands.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/status",
|
||||||
|
description:
|
||||||
|
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/assign <number> <model>",
|
||||||
|
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/start <number>",
|
||||||
|
description:
|
||||||
|
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/show <number>",
|
||||||
|
description: "Display the full text of a work item.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/move <number> <stage>",
|
||||||
|
description:
|
||||||
|
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/delete <number>",
|
||||||
|
description:
|
||||||
|
"Remove a work item from the pipeline and stop any running agent.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/cost",
|
||||||
|
description:
|
||||||
|
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/git",
|
||||||
|
description:
|
||||||
|
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/overview <number>",
|
||||||
|
description: "Show the implementation summary for a merged story.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/rebuild",
|
||||||
|
description: "Rebuild the server binary and restart.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/reset",
|
||||||
|
description:
|
||||||
|
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/btw <question>",
|
||||||
|
description:
|
||||||
|
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -108,17 +108,13 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the blocked flag if present.
|
// Clear the blocked flag if present.
|
||||||
if has_blocked {
|
if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
|
||||||
if let Err(e) = clear_front_matter_field(path, "blocked") {
|
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
||||||
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear merge_failure if present.
|
// Clear merge_failure if present.
|
||||||
if has_merge_failure {
|
if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
||||||
if let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
||||||
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<Str
|
|||||||
|
|
||||||
/// Return true if the project directory has no meaningful source files.
|
/// Return true if the project directory has no meaningful source files.
|
||||||
fn is_bare_project(project_root: &Path) -> bool {
|
fn is_bare_project(project_root: &Path) -> bool {
|
||||||
let dominated_by_storkit = std::fs::read_dir(project_root)
|
std::fs::read_dir(project_root)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|entries| {
|
.map(|entries| {
|
||||||
let names: Vec<String> = entries
|
let names: Vec<String> = entries
|
||||||
@@ -171,8 +171,7 @@ fn is_bare_project(project_root: &Path) -> bool {
|
|||||||
|| n == "store.json"
|
|| n == "store.json"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(true);
|
.unwrap_or(true)
|
||||||
dominated_by_storkit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a generation hint for a step based on the project root.
|
/// Return a generation hint for a step based on the project root.
|
||||||
|
|||||||
Reference in New Issue
Block a user