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 () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
@@ -1551,6 +1555,10 @@ describe("Slash command handling (Story 374)", () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
@@ -1569,6 +1577,10 @@ describe("Slash command handling (Story 374)", () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
@@ -1595,6 +1607,10 @@ describe("Slash command handling (Story 374)", () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
@@ -1634,6 +1650,10 @@ describe("Slash command handling (Story 374)", () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
@@ -1652,6 +1672,10 @@ describe("Slash command handling (Story 374)", () => {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||
});
|
||||
|
||||
@@ -1059,6 +1059,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
)}
|
||||
{messages.map((msg: Message, idx: number) => (
|
||||
<MessageItem
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
|
||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||
msg={msg}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 { SLASH_COMMANDS } from "../slashCommands";
|
||||
|
||||
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 {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ function MessageItemInner({ msg }: MessageItemProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ToolCall has no stable ID
|
||||
key={`tool-${i}-${tc.function.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -202,6 +202,7 @@ export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
||||
) : (
|
||||
filteredLogs.map((entry, idx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no stable ID
|
||||
key={`${entry.timestamp}-${idx}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
Reference in New Issue
Block a user