Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
prepareCompactionMessage,
executeCompaction,
buildContinueMessage,
processSlashCommand,
type CommandHandlerContext,
} from "@/browser/utils/chatCommands";
import { shouldTriggerAutoCompaction } from "@/browser/utils/compaction/shouldTriggerAutoCompaction";
Expand Down Expand Up @@ -283,6 +284,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
// Attached reviews come from parent via props (persisted in pendingReviews state)
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
const inputRef = useRef<HTMLTextAreaElement>(null);
const resetInputHeight = useCallback(() => {
if (inputRef.current) {
inputRef.current.style.height = "";
}
}, []);
const modelSelectorRef = useRef<ModelSelectorRef>(null);
const [atMentionCursorNonce, setAtMentionCursorNonce] = useState(0);
const lastAtMentionCursorRef = useRef<number | null>(null);
Expand Down Expand Up @@ -1209,9 +1215,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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") {
Expand All @@ -1233,7 +1237,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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);
Expand Down Expand Up @@ -1326,18 +1330,54 @@ const ChatInputInner: React.FC<ChatInputProps> = (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);
Expand All @@ -1361,9 +1401,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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;
}
Expand Down
27 changes: 13 additions & 14 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions src/browser/utils/slashCommands/commandKey.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions src/browser/utils/slashCommands/suggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions src/browser/utils/slashCommands/suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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 [];
}

Expand Down
32 changes: 21 additions & 11 deletions src/constants/slashCommands.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set([
"clear",
"truncate",
"compact",
"fork",
"new",
"plan-show",
"plan-open",
export const CREATION_SUPPORTED_COMMANDS: ReadonlySet<string> = 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);
}
Loading