diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 7bc27e620a..85a788a5d8 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -47,6 +47,7 @@ import { prepareCompactionMessage, executeCompaction, buildContinueMessage, + processSlashCommand, type CommandHandlerContext, } from "@/browser/utils/chatCommands"; import { shouldTriggerAutoCompaction } from "@/browser/utils/compaction/shouldTriggerAutoCompaction"; @@ -283,6 +284,11 @@ const ChatInputInner: React.FC = (props) => { // Attached reviews come from parent via props (persisted in pendingReviews state) const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; const inputRef = useRef(null); + const resetInputHeight = useCallback(() => { + if (inputRef.current) { + inputRef.current.style.height = ""; + } + }, []); const modelSelectorRef = useRef(null); const [atMentionCursorNonce, setAtMentionCursorNonce] = useState(0); const lastAtMentionCursorRef = useRef(null); @@ -1209,9 +1215,7 @@ const ChatInputInner: React.FC = (props) => { // Save the original input in case we need to restore on error const originalInput = input; setInput(""); - if (inputRef.current) { - inputRef.current.style.height = ""; - } + resetInputHeight(); try { if (type === "clear") { @@ -1233,7 +1237,7 @@ const ChatInputInner: React.FC = (props) => { // Restore the input so user can retry setInput(originalInput); } - }, [pendingDestructiveCommand, variant, props, pushToast, setInput, input]); + }, [pendingDestructiveCommand, variant, props, pushToast, resetInputHeight, setInput, input]); const handleDestructiveCommandCancel = useCallback(() => { setPendingDestructiveCommand(null); @@ -1326,18 +1330,54 @@ const ChatInputInner: React.FC = (props) => { return; } + const originalInput = input; const messageText = input.trim(); // Route to creation handler for creation variant if (variant === "creation") { + const parsed = messageText.startsWith("/") ? parseCommand(messageText) : null; + // Handle /init command in creation variant - populate input with init message - if (messageText.startsWith("/")) { - const parsed = parseCommand(messageText); - if (parsed?.type === "init") { - setInput(initMessage); - focusMessageInput(); + if (parsed?.type === "init") { + setInput(initMessage); + focusMessageInput(); + return; + } + + if (parsed) { + if (!api) { + pushToast({ type: "error", message: "Not connected to server" }); return; } + + try { + const result = await processSlashCommand(parsed, { + api, + variant, + sendMessageOptions, + setInput, + setImageAttachments, + setIsSending, + setToast, + resetInputHeight, + onProviderConfig: props.onProviderConfig, + onModelChange: props.onModelChange, + setPreferredModel, + setVimEnabled, + }); + if (!result.clearInput) { + setInput(originalInput); + } + } catch (error) { + console.error("Failed to run slash command:", error); + setIsSending(false); + pushToast({ + type: "error", + message: error instanceof Error ? error.message : "Failed to run command", + }); + setInput(originalInput); + } + return; } setHasAttemptedCreateSend(true); @@ -1361,9 +1401,7 @@ const ChatInputInner: React.FC = (props) => { setImageAttachments([]); // Height is managed by VimTextArea's useLayoutEffect - clear inline style // to let CSS min-height take over - if (inputRef.current) { - inputRef.current.style.height = ""; - } + resetInputHeight(); } return; } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 9173777861..0ae523f62b 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -20,7 +20,8 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; import { RUNTIME_MODE, parseRuntimeModeAndHost } from "@/common/types/runtime"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; -import { WORKSPACE_ONLY_COMMANDS } from "@/constants/slashCommands"; +import { isCommandAvailableInVariant } from "@/constants/slashCommands"; +import { getCommandKeyFromParsed } from "@/browser/utils/slashCommands/commandKey"; import type { Toast } from "@/browser/components/ChatInputToast"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { formatCompactionCommandLine } from "@/browser/utils/compaction/format"; @@ -248,20 +249,18 @@ export async function processSlashCommand( return { clearInput: true, toastShown: false }; } - // 2. Workspace Commands - const isWorkspaceCommand = WORKSPACE_ONLY_COMMANDS.has(parsed.type); - - if (isWorkspaceCommand) { - if (variant !== "workspace") { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Command not available during workspace creation", - }); - return { clearInput: false, toastShown: true }; - } + const commandKey = getCommandKeyFromParsed(parsed); + if (commandKey && !isCommandAvailableInVariant(commandKey, variant)) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Command not available during workspace creation", + }); + return { clearInput: false, toastShown: true }; + } - // Dispatch workspace commands + // 2. Workspace Commands + if (variant === "workspace") { switch (parsed.type) { case "clear": return handleClearCommand(parsed, context); diff --git a/src/browser/utils/slashCommands/commandKey.ts b/src/browser/utils/slashCommands/commandKey.ts new file mode 100644 index 0000000000..044ba94552 --- /dev/null +++ b/src/browser/utils/slashCommands/commandKey.ts @@ -0,0 +1,61 @@ +import type { ParsedCommand } from "./types"; + +export function getCommandKeyFromParsed(parsed: ParsedCommand): string | null { + if (!parsed) return null; + + switch (parsed.type) { + case "providers-set": + case "providers-help": + case "providers-invalid-subcommand": + case "providers-missing-args": + return "providers"; + + case "model-set": + case "model-help": + return "model"; + + case "vim-toggle": + return "vim"; + + case "init": + return "init"; + + case "clear": + return "clear"; + + case "truncate": + return "truncate"; + + case "compact": + return "compact"; + + case "fork": + case "fork-help": + return "fork"; + + case "new": + return "new"; + + case "plan-show": + case "plan-open": + return "plan"; + + case "mcp-add": + case "mcp-edit": + case "mcp-remove": + case "mcp-open": + return "mcp"; + + case "idle-compaction": + return "idle"; + + case "debug-llm-request": + return "debug-llm-request"; + + case "unknown-command": + return null; + + default: + return null; + } +} diff --git a/src/browser/utils/slashCommands/suggestions.test.ts b/src/browser/utils/slashCommands/suggestions.test.ts index def6a313ac..ad501d2d96 100644 --- a/src/browser/utils/slashCommands/suggestions.test.ts +++ b/src/browser/utils/slashCommands/suggestions.test.ts @@ -7,6 +7,18 @@ describe("getSlashCommandSuggestions", () => { expect(getSlashCommandSuggestions("")).toEqual([]); }); + it("filters commands for creation variant", () => { + const suggestions = getSlashCommandSuggestions("/", { variant: "creation" }); + const labels = suggestions.map((s) => s.display); + + expect(labels).toContain("/init"); + expect(labels).toContain("/model"); + expect(labels).toContain("/providers"); + expect(labels).toContain("/vim"); + expect(labels).not.toContain("/clear"); + expect(labels).not.toContain("/mcp"); + }); + it("suggests top level commands when starting with slash", () => { const suggestions = getSlashCommandSuggestions("/"); const labels = suggestions.map((s) => s.display); diff --git a/src/browser/utils/slashCommands/suggestions.ts b/src/browser/utils/slashCommands/suggestions.ts index e6a23f01ac..5947b657a6 100644 --- a/src/browser/utils/slashCommands/suggestions.ts +++ b/src/browser/utils/slashCommands/suggestions.ts @@ -13,7 +13,7 @@ import type { export type { SlashSuggestion } from "./types"; -import { WORKSPACE_ONLY_COMMANDS } from "@/constants/slashCommands"; +import { isCommandAvailableInVariant } from "@/constants/slashCommands"; const COMMAND_DEFINITIONS = getSlashCommandDefinitions(); @@ -52,8 +52,8 @@ function buildTopLevelSuggestions( replacement, }; }, - // In creation mode, filter out workspace-only commands - isCreation ? (definition) => !WORKSPACE_ONLY_COMMANDS.has(definition.key) : undefined + // In creation mode, only show commands that are available before a workspace exists + isCreation ? (definition) => isCommandAvailableInVariant(definition.key, "creation") : undefined ); } @@ -111,8 +111,8 @@ export function getSlashCommandSuggestions( return []; } - // In creation mode, don't show subcommand suggestions for workspace-only commands - if (context.variant === "creation" && WORKSPACE_ONLY_COMMANDS.has(rootKey)) { + // In creation mode, don't show subcommand suggestions for unavailable commands + if (context.variant === "creation" && !isCommandAvailableInVariant(rootKey, "creation")) { return []; } diff --git a/src/constants/slashCommands.ts b/src/constants/slashCommands.ts index 3e81dadd7e..d75d4e7f1e 100644 --- a/src/constants/slashCommands.ts +++ b/src/constants/slashCommands.ts @@ -1,17 +1,27 @@ /** - * Slash command constants shared between suggestion filtering and command execution. + * Slash command availability shared between suggestion filtering and command execution. */ +export type SlashCommandVariant = "workspace" | "creation"; + /** - * Commands that only work in workspace context (not during creation). - * These commands require an existing workspace with conversation history. + * Commands that are safe to run before a workspace exists (creation flow). + * Keep this list intentionally small so unsupported commands never appear. */ -export const WORKSPACE_ONLY_COMMANDS: ReadonlySet = new Set([ - "clear", - "truncate", - "compact", - "fork", - "new", - "plan-show", - "plan-open", +export const CREATION_SUPPORTED_COMMANDS: ReadonlySet = new Set([ + "init", + "model", + "providers", + "vim", ]); + +export function isCommandAvailableInVariant( + commandKey: string, + variant: SlashCommandVariant +): boolean { + if (variant === "workspace") { + return true; + } + + return CREATION_SUPPORTED_COMMANDS.has(commandKey); +}