From 56e8964173aed2d2d769ed2cf4b1c22d30260be5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 19:41:26 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20Implement=20/new=20command?= =?UTF-8?q?=20with=20modal=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added /new [-t ] slash command - Extracted command handling to chatCommands.ts utility - NewWorkspaceModal displays equivalent command - Shared workspace creation logic via backend API - Comprehensive test coverage (11 tests for /new) Generated with `cmux` --- src/components/ChatInput.tsx | 139 ++++------ src/components/CommandPalette.tsx | 26 ++ src/components/NewWorkspaceModal.tsx | 30 +++ src/constants/events.ts | 6 + src/utils/chatCommands.ts | 372 +++++++++++++++++++++++++++ src/utils/commands/sources.ts | 55 +--- src/utils/slashCommands/fork.test.ts | 64 +++++ src/utils/slashCommands/new.test.ts | 113 ++++++++ src/utils/slashCommands/registry.ts | 144 +++++++++-- src/utils/slashCommands/types.ts | 8 + 10 files changed, 785 insertions(+), 172 deletions(-) create mode 100644 src/utils/chatCommands.ts create mode 100644 src/utils/slashCommands/fork.test.ts create mode 100644 src/utils/slashCommands/new.test.ts diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index b858cbcd5..c5724e608 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -10,7 +10,13 @@ import { useMode } from "@/contexts/ModeContext"; import { ChatToggles } from "./ChatToggles"; import { useSendMessageOptions } from "@/hooks/useSendMessageOptions"; import { getModelKey, getInputKey } from "@/constants/storage"; -import { forkWorkspace } from "@/utils/workspaceFork"; +import { + handleNewCommand, + handleCompactCommand, + forkWorkspace, + prepareCompactionMessage, + type CommandHandlerContext, +} from "@/utils/chatCommands"; import { ToggleGroup } from "./ToggleGroup"; import { CUSTOM_EVENTS } from "@/constants/events"; import type { UIMode } from "@/types/mode"; @@ -31,10 +37,7 @@ import { } from "@/utils/imageHandling"; import type { ThinkingLevel } from "@/types/thinking"; -import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message"; -import type { SendMessageOptions } from "@/types/ipc"; -import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; -import { resolveCompactionModel } from "@/utils/messages/compactionModelPreference"; +import type { CmuxFrontendMetadata } from "@/types/message"; import { useTelemetry } from "@/hooks/useTelemetry"; import { setTelemetryEnabled } from "@/telemetry"; @@ -159,49 +162,6 @@ export interface ChatInputProps { } // Helper function to convert parsed command to display toast -/** - * Prepare compaction message from /compact command - * Returns the actual message text (summarization request), metadata, and options - */ -function prepareCompactionMessage( - command: string, - sendMessageOptions: SendMessageOptions -): { - messageText: string; - metadata: CmuxFrontendMetadata; - options: Partial; -} { - const parsed = parseCommand(command); - if (parsed?.type !== "compact") { - throw new Error("Not a compact command"); - } - - const targetWords = parsed.maxOutputTokens ? Math.round(parsed.maxOutputTokens / 1.3) : 2000; - - const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; - - // Handle model preference (sticky globally) - const effectiveModel = resolveCompactionModel(parsed.model); - - // Create compaction metadata (will be stored in user message) - const compactData: CompactionRequestData = { - model: effectiveModel, - maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, - }; - - const metadata: CmuxFrontendMetadata = { - type: "compaction-request", - rawCommand: command, - parsed: compactData, - }; - - // Apply compaction overrides using shared transformation function - // This same function is used by useResumeManager to ensure consistency - const options = applyCompactionOverrides(sendMessageOptions, compactData); - - return { messageText, metadata, options }; -} export const ChatInput: React.FC = ({ workspaceId, @@ -572,51 +532,19 @@ export const ChatInput: React.FC = ({ // Handle /compact command if (parsed.type === "compact") { - setInput(""); // Clear input immediately - setIsSending(true); - - try { - const { - messageText: compactionMessage, - metadata, - options, - } = prepareCompactionMessage(messageText, sendMessageOptions); - - const result = await window.api.workspace.sendMessage(workspaceId, compactionMessage, { - ...sendMessageOptions, - ...options, - cmuxMetadata: metadata, - editMessageId: editingMessage?.id, // Support editing compaction messages - }); + const context: CommandHandlerContext = { + workspaceId, + sendMessageOptions, + editMessageId: editingMessage?.id, + setInput, + setIsSending, + setToast, + onCancelEdit, + }; - if (!result.success) { - console.error("Failed to initiate compaction:", result.error); - setToast(createErrorToast(result.error)); - setInput(messageText); // Restore input on error - } else { - setToast({ - id: Date.now().toString(), - type: "success", - message: - metadata.type === "compaction-request" && metadata.parsed.continueMessage - ? "Compaction started. Will continue automatically after completion." - : "Compaction started. AI will summarize the conversation.", - }); - // Clear editing state on success - if (editingMessage && onCancelEdit) { - onCancelEdit(); - } - } - } catch (error) { - console.error("Compaction error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to start compaction", - }); + const result = await handleCompactCommand(parsed, context); + if (!result.clearInput) { setInput(messageText); // Restore input on error - } finally { - setIsSending(false); } return; } @@ -667,6 +595,23 @@ export const ChatInput: React.FC = ({ return; } + // Handle /new command + if (parsed.type === "new") { + const context: CommandHandlerContext = { + workspaceId, + sendMessageOptions, + setInput, + setIsSending, + setToast, + }; + + const result = await handleNewCommand(parsed, context); + if (!result.clearInput) { + setInput(messageText); // Restore input on error + } + return; + } + // Handle all other commands - show display toast const commandToast = createCommandToast(parsed); if (commandToast) { @@ -719,11 +664,17 @@ export const ChatInput: React.FC = ({ const { messageText: regeneratedText, metadata, - options, - } = prepareCompactionMessage(messageText, sendMessageOptions); + sendOptions, + } = prepareCompactionMessage({ + workspaceId, + maxOutputTokens: parsed.maxOutputTokens, + continueMessage: parsed.continueMessage, + model: parsed.model, + sendMessageOptions, + }); actualMessageText = regeneratedText; cmuxMetadata = metadata; - compactionOptions = options; + compactionOptions = sendOptions; } } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index b87a08f99..59da464b4 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -93,6 +93,8 @@ const ShortcutHint = styled.span` font-family: var(--font-monospace); `; + + interface CommandPaletteProps { getSlashContext?: () => { providerNames: string[]; workspaceId?: string }; } @@ -177,6 +179,27 @@ export const CommandPalette: React.FC = ({ getSlashContext }); }, []); + // Listen for EXECUTE_COMMAND events + useEffect(() => { + const handleExecuteCommand = (e: Event) => { + const customEvent = e as CustomEvent<{ commandId: string }>; + const { commandId } = customEvent.detail; + + const action = getActions().find(a => a.id === commandId); + if (!action) { + console.warn(`Command not found: ${commandId}`); + return; + } + + // Run the action directly + void action.run(); + addRecent(action.id); + }; + + window.addEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand); + return () => window.removeEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand); + }, [getActions, startPrompt, addRecent]); + const handlePromptValue = useCallback( (value: string) => { let nextInitial: string | null = null; @@ -237,6 +260,9 @@ export const CommandPalette: React.FC = ({ getSlashContext [activePrompt] ); + + + const generalResults = useMemo(() => { const q = query.trim(); diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 944fe7b49..a6ca70617 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useId, useState } from "react"; import styled from "@emotion/styled"; import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { formatNewCommand } from "@/utils/chatCommands"; const FormGroup = styled.div` margin-bottom: 20px; @@ -61,6 +62,26 @@ const UnderlinedLabel = styled.span` cursor: help; `; +const CommandDisplay = styled.div` + margin-top: 20px; + padding: 12px; + background: #1e1e1e; + border: 1px solid #3e3e42; + border-radius: 4px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + font-size: 13px; + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-all; +`; + +const CommandLabel = styled.div` + font-size: 12px; + color: #888; + margin-bottom: 8px; + font-family: system-ui, -apple-system, sans-serif; +`; + interface NewWorkspaceModalProps { isOpen: boolean; projectName: string; @@ -236,6 +257,15 @@ const NewWorkspaceModal: React.FC = ({ + {branchName.trim() && ( +
+ Equivalent command: + + {formatNewCommand(branchName.trim(), trunkBranch.trim() || undefined)} + +
+ )} + Cancel diff --git a/src/constants/events.ts b/src/constants/events.ts index ec8e6f5e1..9f91f2e59 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -40,6 +40,12 @@ export const CUSTOM_EVENTS = { * Detail: { workspaceId: string, projectPath: string, projectName: string, workspacePath: string, branch: string } */ WORKSPACE_FORK_SWITCH: "cmux:workspaceForkSwitch", + + /** + * Event to execute a command from the command palette + * Detail: { commandId: string } + */ + EXECUTE_COMMAND: "cmux:executeCommand", } as const; /** diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts new file mode 100644 index 000000000..6a54b6eaa --- /dev/null +++ b/src/utils/chatCommands.ts @@ -0,0 +1,372 @@ +/** + * Chat command execution utilities + * Handles executing workspace operations from slash commands + * + * These utilities are shared between ChatInput command handlers and UI components + * to ensure consistent behavior and avoid duplication. + */ + +import type { SendMessageOptions } from "@/types/ipc"; +import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; +import { CUSTOM_EVENTS } from "@/constants/events"; +import type { Toast } from "@/components/ChatInputToast"; +import type { ParsedCommand } from "@/utils/slashCommands/types"; +import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; +import { resolveCompactionModel } from "@/utils/messages/compactionModelPreference"; + +// ============================================================================ +// Workspace Creation +// ============================================================================ + +export interface CreateWorkspaceOptions { + projectPath: string; + workspaceName: string; + trunkBranch?: string; + startMessage?: string; + sendMessageOptions?: SendMessageOptions; +} + +export interface CreateWorkspaceResult { + success: boolean; + workspaceInfo?: FrontendWorkspaceMetadata; + error?: string; +} + +/** + * Create a new workspace and switch to it + * Handles backend creation, dispatching switch event, and optionally sending start message + * + * Shared between /new command and NewWorkspaceModal + */ +export async function createNewWorkspace( + options: CreateWorkspaceOptions +): Promise { + // Get recommended trunk if not provided + let effectiveTrunk = options.trunkBranch; + if (!effectiveTrunk) { + const { recommendedTrunk } = await window.api.projects.listBranches(options.projectPath); + effectiveTrunk = recommendedTrunk ?? "main"; + } + + const result = await window.api.workspace.create( + options.projectPath, + options.workspaceName, + effectiveTrunk + ); + + if (!result.success) { + return { success: false, error: result.error ?? "Failed to create workspace" }; + } + + // Get workspace info for switching + const workspaceInfo = await window.api.workspace.getInfo(result.metadata.id); + if (!workspaceInfo) { + return { success: false, error: "Failed to get workspace info after creation" }; + } + + // Dispatch event to switch workspace + dispatchWorkspaceSwitch(workspaceInfo); + + // If there's a start message, defer until React finishes rendering and WorkspaceStore subscribes + if (options.startMessage && options.sendMessageOptions) { + requestAnimationFrame(() => { + void window.api.workspace.sendMessage( + result.metadata.id, + options.startMessage!, + options.sendMessageOptions + ); + }); + } + + return { success: true, workspaceInfo }; +} + +/** + * Format /new command string for display + */ +export function formatNewCommand( + workspaceName: string, + trunkBranch?: string, + startMessage?: string +): string { + let cmd = `/new ${workspaceName}`; + if (trunkBranch) { + cmd += ` -t ${trunkBranch}`; + } + if (startMessage) { + cmd += `\n${startMessage}`; + } + return cmd; +} + +// ============================================================================ +// Workspace Forking (re-exported from workspaceFork for convenience) +// ============================================================================ + +export { forkWorkspace, type ForkOptions, type ForkResult } from "./workspaceFork"; + +// ============================================================================ +// Compaction +// ============================================================================ + +export interface CompactionOptions { + workspaceId: string; + maxOutputTokens?: number; + continueMessage?: string; + model?: string; + sendMessageOptions: SendMessageOptions; + editMessageId?: string; +} + +export interface CompactionResult { + success: boolean; + error?: string; +} + +/** + * Prepare compaction message from options + * Returns the actual message text (summarization request), metadata, and options + */ +export function prepareCompactionMessage( + options: CompactionOptions +): { + messageText: string; + metadata: CmuxFrontendMetadata; + sendOptions: SendMessageOptions; +} { + const targetWords = options.maxOutputTokens ? Math.round(options.maxOutputTokens / 1.3) : 2000; + + const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; + + // Handle model preference (sticky globally) + const effectiveModel = resolveCompactionModel(options.model); + + // Create compaction metadata (will be stored in user message) + const compactData: CompactionRequestData = { + model: effectiveModel, + maxOutputTokens: options.maxOutputTokens, + continueMessage: options.continueMessage, + }; + + const metadata: CmuxFrontendMetadata = { + type: "compaction-request", + rawCommand: formatCompactionCommand(options), + parsed: compactData, + }; + + // Apply compaction overrides + const sendOptions = applyCompactionOverrides(options.sendMessageOptions, compactData); + + return { messageText, metadata, sendOptions }; +} + +/** + * Execute a compaction command + */ +export async function executeCompaction(options: CompactionOptions): Promise { + const { messageText, metadata, sendOptions } = prepareCompactionMessage(options); + + const result = await window.api.workspace.sendMessage(options.workspaceId, messageText, { + ...sendOptions, + cmuxMetadata: metadata, + editMessageId: options.editMessageId, + }); + + if (!result.success) { + // Convert SendMessageError to string for error display + const errorString = result.error + ? typeof result.error === "string" + ? result.error + : "type" in result.error + ? result.error.type + : "Failed to compact" + : undefined; + return { success: false, error: errorString }; + } + + return { success: true }; +} + +/** + * Format compaction command string for display + */ +function formatCompactionCommand(options: CompactionOptions): string { + let cmd = "/compact"; + if (options.maxOutputTokens) { + cmd += ` -t ${options.maxOutputTokens}`; + } + if (options.model) { + cmd += ` -m ${options.model}`; + } + if (options.continueMessage) { + cmd += `\n${options.continueMessage}`; + } + return cmd; +} + +// ============================================================================ +// Command Handler Types +// ============================================================================ + +export interface CommandHandlerContext { + workspaceId: string; + sendMessageOptions: SendMessageOptions; + editMessageId?: string; + setInput: (value: string) => void; + setIsSending: (value: boolean) => void; + setToast: (toast: Toast) => void; + onCancelEdit?: () => void; +} + +export interface CommandHandlerResult { + /** Whether the input should be cleared */ + clearInput: boolean; + /** Whether to show a toast (already set via context.setToast) */ + toastShown: boolean; +} + +/** + * Handle /new command execution + */ +export async function handleNewCommand( + parsed: Extract, + context: CommandHandlerContext +): Promise { + const { workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context; + + // Open modal if no workspace name provided + if (!parsed.workspaceName) { + setInput(""); + const event = new CustomEvent(CUSTOM_EVENTS.EXECUTE_COMMAND, { + detail: { commandId: "ws:new" }, + }); + window.dispatchEvent(event); + return { clearInput: true, toastShown: false }; + } + + setInput(""); + setIsSending(true); + + try { + // Get workspace info to extract projectPath + const workspaceInfo = await window.api.workspace.getInfo(workspaceId); + if (!workspaceInfo) { + throw new Error("Failed to get workspace info"); + } + + const createResult = await createNewWorkspace({ + projectPath: workspaceInfo.projectPath, + workspaceName: parsed.workspaceName, + trunkBranch: parsed.trunkBranch, + startMessage: parsed.startMessage, + sendMessageOptions, + }); + + if (!createResult.success) { + const errorMsg = createResult.error ?? "Failed to create workspace"; + console.error("Failed to create workspace:", errorMsg); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Create Failed", + message: errorMsg, + }); + return { clearInput: false, toastShown: true }; + } + + setToast({ + id: Date.now().toString(), + type: "success", + message: `Created workspace "${parsed.workspaceName}"`, + }); + return { clearInput: true, toastShown: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Failed to create workspace"; + console.error("Create error:", error); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Create Failed", + message: errorMsg, + }); + return { clearInput: false, toastShown: true }; + } finally { + setIsSending(false); + } +} + +/** + * Handle /compact command execution + */ +export async function handleCompactCommand( + parsed: Extract, + context: CommandHandlerContext +): Promise { + const { workspaceId, sendMessageOptions, editMessageId, setInput, setIsSending, setToast, onCancelEdit } = context; + + setInput(""); + setIsSending(true); + + try { + const result = await executeCompaction({ + workspaceId, + maxOutputTokens: parsed.maxOutputTokens, + continueMessage: parsed.continueMessage, + model: parsed.model, + sendMessageOptions, + editMessageId, + }); + + if (!result.success) { + console.error("Failed to initiate compaction:", result.error); + const errorMsg = result.error ?? "Failed to start compaction"; + setToast({ + id: Date.now().toString(), + type: "error", + message: errorMsg, + }); + return { clearInput: false, toastShown: true }; + } + + setToast({ + id: Date.now().toString(), + type: "success", + message: parsed.continueMessage + ? "Compaction started. Will continue automatically after completion." + : "Compaction started. AI will summarize the conversation.", + }); + + // Clear editing state on success + if (editMessageId && onCancelEdit) { + onCancelEdit(); + } + + return { clearInput: true, toastShown: true }; + } catch (error) { + console.error("Compaction error:", error); + setToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to start compaction", + }); + return { clearInput: false, toastShown: true }; + } finally { + setIsSending(false); + } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Dispatch a custom event to switch workspaces + */ +export function dispatchWorkspaceSwitch(workspaceInfo: FrontendWorkspaceMetadata): void { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, { + detail: workspaceInfo, + }) + ); +} diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index a3474d518..9c5fa9793 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -61,65 +61,20 @@ const section = { export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { const actions: Array<() => CommandAction[]> = []; + // NOTE: We intentionally just open the NewWorkspaceModal instead of implementing + // an interactive prompt in the CommandPalette. This avoids duplicating UI logic + // and ensures consistency - both `/new` command and the command palette use the + // same modal for workspace creation. const createWorkspaceForSelectedProjectAction = ( selected: NonNullable ): CommandAction => { - let cachedBranchInfo: BranchListResult | null = null; - const getBranchInfo = async () => { - cachedBranchInfo ??= await p.getBranchesForProject(selected.projectPath); - return cachedBranchInfo; - }; - return { id: "ws:new", title: "Create New Workspace…", subtitle: `for ${selected.projectName}`, section: section.workspaces, shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), - run: () => undefined, - prompt: { - title: "New Workspace", - fields: [ - { - type: "text", - name: "branchName", - label: "Workspace branch name", - placeholder: "Enter branch name", - validate: (v) => (!v.trim() ? "Branch name is required" : null), - }, - { - type: "select", - name: "trunkBranch", - label: "Trunk branch", - placeholder: "Search branches…", - getOptions: async () => { - const info = await getBranchInfo(); - return info.branches.map((branch) => ({ - id: branch, - label: branch, - keywords: [branch], - })); - }, - }, - ], - onSubmit: async (vals) => { - const trimmedBranchName = vals.branchName.trim(); - const info = await getBranchInfo(); - const providedTrunk = vals.trunkBranch?.trim(); - const resolvedTrunk = - providedTrunk && info.branches.includes(providedTrunk) - ? providedTrunk - : info.branches.includes(info.recommendedTrunk) - ? info.recommendedTrunk - : info.branches[0]; - - if (!resolvedTrunk) { - throw new Error("Unable to determine trunk branch for workspace creation"); - } - - await p.onCreateWorkspace(selected.projectPath, trimmedBranchName, resolvedTrunk); - }, - }, + run: () => p.onOpenNewWorkspaceModal(selected.projectPath), }; }; diff --git a/src/utils/slashCommands/fork.test.ts b/src/utils/slashCommands/fork.test.ts new file mode 100644 index 000000000..c39458079 --- /dev/null +++ b/src/utils/slashCommands/fork.test.ts @@ -0,0 +1,64 @@ +import { parseCommand } from "./parser"; + +describe("/fork command", () => { + it("should parse /fork without arguments to show help", () => { + const result = parseCommand("/fork"); + expect(result).toEqual({ + type: "fork-help", + }); + }); + + it("should parse /fork with new name", () => { + const result = parseCommand("/fork new-workspace"); + expect(result).toEqual({ + type: "fork", + newName: "new-workspace", + startMessage: undefined, + }); + }); + + it("should parse /fork with name and start message on same line", () => { + const result = parseCommand("/fork new-workspace Continue with feature X"); + expect(result).toEqual({ + type: "fork", + newName: "new-workspace", + startMessage: "Continue with feature X", + }); + }); + + it("should parse /fork with name and multiline start message", () => { + const result = parseCommand("/fork new-workspace\nContinue with feature X"); + expect(result).toEqual({ + type: "fork", + newName: "new-workspace", + startMessage: "Continue with feature X", + }); + }); + + it("should prefer multiline content over same-line tokens", () => { + const result = parseCommand("/fork new-workspace same line\nMultiline content"); + expect(result).toEqual({ + type: "fork", + newName: "new-workspace", + startMessage: "Multiline content", + }); + }); + + it("should handle quoted workspace names", () => { + const result = parseCommand('/fork "my workspace"'); + expect(result).toEqual({ + type: "fork", + newName: "my workspace", + startMessage: undefined, + }); + }); + + it("should handle multiline messages with multiple lines", () => { + const result = parseCommand("/fork new-workspace\nLine 1\nLine 2\nLine 3"); + expect(result).toEqual({ + type: "fork", + newName: "new-workspace", + startMessage: "Line 1\nLine 2\nLine 3", + }); + }); +}); diff --git a/src/utils/slashCommands/new.test.ts b/src/utils/slashCommands/new.test.ts new file mode 100644 index 000000000..d0f46bdb9 --- /dev/null +++ b/src/utils/slashCommands/new.test.ts @@ -0,0 +1,113 @@ +import { parseCommand } from "./parser"; + +describe("/new command", () => { + it("should return undefined workspaceName when no arguments provided (opens modal)", () => { + const result = parseCommand("/new"); + expect(result).toEqual({ + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }); + }); + + it("should parse /new with workspace name", () => { + const result = parseCommand("/new feature-branch"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: undefined, + startMessage: undefined, + }); + }); + + it("should parse /new with workspace name and trunk via -t flag", () => { + const result = parseCommand("/new feature-branch -t main"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: "main", + startMessage: undefined, + }); + }); + + it("should parse /new with workspace name and start message", () => { + const result = parseCommand("/new feature-branch\nStart implementing feature X"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: undefined, + startMessage: "Start implementing feature X", + }); + }); + + it("should parse /new with workspace name, trunk via -t, and start message", () => { + const result = parseCommand("/new feature-branch -t main\nStart implementing feature X"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: "main", + startMessage: "Start implementing feature X", + }); + }); + + it("should handle multiline start messages", () => { + const result = parseCommand("/new feature-branch\nLine 1\nLine 2\nLine 3"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: undefined, + startMessage: "Line 1\nLine 2\nLine 3", + }); + }); + + it("should return undefined workspaceName for extra positional arguments (opens modal)", () => { + const result = parseCommand("/new feature-branch extra"); + expect(result).toEqual({ + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }); + }); + + it("should handle quoted workspace names", () => { + const result = parseCommand('/new "my feature"'); + expect(result).toEqual({ + type: "new", + workspaceName: "my feature", + trunkBranch: undefined, + startMessage: undefined, + }); + }); + + it("should return undefined workspaceName for unknown flags (opens modal)", () => { + const result = parseCommand("/new feature-branch -x invalid"); + expect(result).toEqual({ + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }); + }); + + it("should handle -t flag with quoted branch name", () => { + const result = parseCommand('/new feature-branch -t "release/v1.0"'); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: "release/v1.0", + startMessage: undefined, + }); + }); + + it("should handle -t flag before workspace name", () => { + const result = parseCommand("/new -t main feature-branch"); + expect(result).toEqual({ + type: "new", + workspaceName: "feature-branch", + trunkBranch: "main", + startMessage: undefined, + }); + }); +}); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index 74395bf76..b184a85a7 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -10,6 +10,34 @@ import type { } from "./types"; import minimist from "minimist"; +/** + * Parse multiline command input into first-line tokens and remaining message + * Used by commands that support messages on subsequent lines (/compact, /fork, /new) + */ +function parseMultilineCommand(rawInput: string): { + firstLine: string; + tokens: string[]; + message: string | undefined; + hasMultiline: boolean; +} { + const hasMultiline = rawInput.includes("\n"); + const lines = rawInput.split("\n"); + const firstLine = lines[0]; + const remainingLines = lines.slice(1).join("\n").trim(); + + // Tokenize first line only (preserving quotes) + const tokens = (firstLine.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((token) => + token.replace(/^"(.*)"$/, "$1") + ); + + return { + firstLine, + tokens, + message: remainingLines.length > 0 ? remainingLines : undefined, + hasMultiline, + }; +} + // Model abbreviations for common models // Order matters: first model becomes the default for new chats export const MODEL_ABBREVIATIONS: Record = { @@ -174,18 +202,7 @@ const compactCommandDefinition: SlashCommandDefinition = { description: "Compact conversation history using AI summarization. Use -t to set max output tokens, -m to set compaction model. Add continue message on lines after the command.", handler: ({ rawInput }): ParsedCommand => { - // Split rawInput into first line (for flags) and remaining lines (for multiline continue) - // rawInput format: "-t 5000\nContinue here" or "\nContinue here" (starts with newline if no flags) - const hasMultilineContent = rawInput.includes("\n"); - const lines = rawInput.split("\n"); - const firstLine = lines[0]; // First line contains flags - const remainingLines = lines.slice(1).join("\n").trim(); - - // Tokenize ONLY the first line to extract flags - // This prevents content after newlines from being parsed as flags - const firstLineTokens = (firstLine.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []).map((token) => - token.replace(/^"(.*)"$/, "$1") - ); + const { tokens: firstLineTokens, message: remainingLines, hasMultiline } = parseMultilineCommand(rawInput); // Parse flags from first line using minimist const parsed = minimist(firstLineTokens, { @@ -235,7 +252,7 @@ const compactCommandDefinition: SlashCommandDefinition = { // Reject extra positional arguments UNLESS they're from multiline content // (multiline content gets parsed as positional args by minimist since newlines become spaces) - if (parsed._.length > 0 && !hasMultilineContent) { + if (parsed._.length > 0 && !hasMultiline) { return { type: "unknown-command", command: "compact", @@ -251,7 +268,7 @@ const compactCommandDefinition: SlashCommandDefinition = { if (parsed.c !== undefined && typeof parsed.c === "string" && parsed.c.trim().length > 0) { // -c flag takes precedence (backwards compatibility) continueMessage = parsed.c.trim(); - } else if (remainingLines.length > 0) { + } else if (remainingLines) { // Use multiline content continueMessage = remainingLines; } @@ -438,26 +455,27 @@ const telemetryCommandDefinition: SlashCommandDefinition = { const forkCommandDefinition: SlashCommandDefinition = { key: "fork", - description: "Fork workspace with new name and optional start message", - handler: ({ cleanRemainingTokens, remainingTokens }): ParsedCommand => { - if (cleanRemainingTokens.length === 0) { + description: "Fork workspace with new name and optional start message. Add start message on lines after the command.", + handler: ({ rawInput }): ParsedCommand => { + const { tokens, message } = parseMultilineCommand(rawInput); + + if (tokens.length === 0) { return { type: "fork-help", }; } - const newName = cleanRemainingTokens[0]; + const newName = tokens[0]; - // Everything after the first token (workspace name) becomes the start message - // We need to reconstruct from remainingTokens to preserve quotes - const startMessage = - remainingTokens.length > 1 - ? remainingTokens - .slice(1) - .map((token) => token.replace(/^"(.*)"$/, "$1")) - .join(" ") - .trim() - : undefined; + // Start message can be from remaining tokens on same line or multiline content + let startMessage: string | undefined; + if (message) { + // Multiline content takes precedence + startMessage = message; + } else if (tokens.length > 1) { + // Join remaining tokens on first line + startMessage = tokens.slice(1).join(" ").trim(); + } return { type: "fork", @@ -467,6 +485,75 @@ const forkCommandDefinition: SlashCommandDefinition = { }, }; +const newCommandDefinition: SlashCommandDefinition = { + key: "new", + description: "Create new workspace with optional trunk branch. Use -t to specify trunk. Add start message on lines after the command.", + handler: ({ rawInput }): ParsedCommand => { + const { tokens: firstLineTokens, message: remainingLines, hasMultiline } = parseMultilineCommand(rawInput); + + // Parse flags from first line using minimist + const parsed = minimist(firstLineTokens, { + string: ["t"], + unknown: (arg: string) => { + // Unknown flags starting with - are errors + if (arg.startsWith("-")) { + return false; + } + return true; + }, + }); + + // Check for unknown flags - return undefined workspaceName to open modal + const unknownFlags = firstLineTokens.filter( + (token) => token.startsWith("-") && token !== "-t" + ); + if (unknownFlags.length > 0) { + return { + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }; + } + + // No workspace name provided - return undefined to open modal + if (parsed._.length === 0) { + return { + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }; + } + + // Get workspace name (first positional argument) + const workspaceName = String(parsed._[0]); + + // Reject extra positional arguments - return undefined to open modal + if (parsed._.length > 1 && !hasMultiline) { + return { + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + startMessage: undefined, + }; + } + + // Get trunk branch from -t flag + let trunkBranch: string | undefined; + if (parsed.t !== undefined && typeof parsed.t === "string" && parsed.t.trim().length > 0) { + trunkBranch = parsed.t.trim(); + } + + return { + type: "new", + workspaceName, + trunkBranch, + startMessage: remainingLines, + }; + }, +}; + export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ clearCommandDefinition, truncateCommandDefinition, @@ -475,6 +562,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ providersCommandDefinition, telemetryCommandDefinition, forkCommandDefinition, + newCommandDefinition, ]; export const SLASH_COMMAND_DEFINITION_MAP = new Map( diff --git a/src/utils/slashCommands/types.ts b/src/utils/slashCommands/types.ts index 304017055..996b318bb 100644 --- a/src/utils/slashCommands/types.ts +++ b/src/utils/slashCommands/types.ts @@ -1,5 +1,12 @@ /** * Shared types for slash command system + * + * NOTE: `/-help` types are an anti-pattern. Commands should prefer opening + * a modal when misused or called with no arguments, rather than showing help toasts. + * This provides a better UX by guiding users through the UI instead of showing text. + * + * Existing `-help` types are kept for backward compatibility but should not be added + * for new commands. */ export type ParsedCommand = @@ -16,6 +23,7 @@ export type ParsedCommand = | { type: "telemetry-help" } | { type: "fork"; newName: string; startMessage?: string } | { type: "fork-help" } + | { type: "new"; workspaceName?: string; trunkBranch?: string; startMessage?: string } | { type: "unknown-command"; command: string; subcommand?: string } | null; From 829937cc5f035bc45c9b50c4d5eea10cae4e029f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 19:58:48 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CommandPalette.tsx | 13 ++++--------- src/components/NewWorkspaceModal.tsx | 5 ++++- src/utils/chatCommands.ts | 24 +++++++++++++++--------- src/utils/slashCommands/registry.ts | 22 +++++++++++++++------- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 59da464b4..b63a3e252 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -93,8 +93,6 @@ const ShortcutHint = styled.span` font-family: var(--font-monospace); `; - - interface CommandPaletteProps { getSlashContext?: () => { providerNames: string[]; workspaceId?: string }; } @@ -184,18 +182,18 @@ export const CommandPalette: React.FC = ({ getSlashContext const handleExecuteCommand = (e: Event) => { const customEvent = e as CustomEvent<{ commandId: string }>; const { commandId } = customEvent.detail; - - const action = getActions().find(a => a.id === commandId); + + const action = getActions().find((a) => a.id === commandId); if (!action) { console.warn(`Command not found: ${commandId}`); return; } - + // Run the action directly void action.run(); addRecent(action.id); }; - + window.addEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand); return () => window.removeEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand); }, [getActions, startPrompt, addRecent]); @@ -260,9 +258,6 @@ export const CommandPalette: React.FC = ({ getSlashContext [activePrompt] ); - - - const generalResults = useMemo(() => { const q = query.trim(); diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index a6ca70617..4ef7f894b 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -79,7 +79,10 @@ const CommandLabel = styled.div` font-size: 12px; color: #888; margin-bottom: 8px; - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; `; interface NewWorkspaceModalProps { diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index 6a54b6eaa..ce233ef6b 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -128,9 +128,7 @@ export interface CompactionResult { * Prepare compaction message from options * Returns the actual message text (summarization request), metadata, and options */ -export function prepareCompactionMessage( - options: CompactionOptions -): { +export function prepareCompactionMessage(options: CompactionOptions): { messageText: string; metadata: CmuxFrontendMetadata; sendOptions: SendMessageOptions; @@ -175,11 +173,11 @@ export async function executeCompaction(options: CompactionOptions): Promise, context: CommandHandlerContext ): Promise { - const { workspaceId, sendMessageOptions, editMessageId, setInput, setIsSending, setToast, onCancelEdit } = context; + const { + workspaceId, + sendMessageOptions, + editMessageId, + setInput, + setIsSending, + setToast, + onCancelEdit, + } = context; setInput(""); setIsSending(true); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index b184a85a7..7d1d6038d 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -202,7 +202,11 @@ const compactCommandDefinition: SlashCommandDefinition = { description: "Compact conversation history using AI summarization. Use -t to set max output tokens, -m to set compaction model. Add continue message on lines after the command.", handler: ({ rawInput }): ParsedCommand => { - const { tokens: firstLineTokens, message: remainingLines, hasMultiline } = parseMultilineCommand(rawInput); + const { + tokens: firstLineTokens, + message: remainingLines, + hasMultiline, + } = parseMultilineCommand(rawInput); // Parse flags from first line using minimist const parsed = minimist(firstLineTokens, { @@ -455,7 +459,8 @@ const telemetryCommandDefinition: SlashCommandDefinition = { const forkCommandDefinition: SlashCommandDefinition = { key: "fork", - description: "Fork workspace with new name and optional start message. Add start message on lines after the command.", + description: + "Fork workspace with new name and optional start message. Add start message on lines after the command.", handler: ({ rawInput }): ParsedCommand => { const { tokens, message } = parseMultilineCommand(rawInput); @@ -487,9 +492,14 @@ const forkCommandDefinition: SlashCommandDefinition = { const newCommandDefinition: SlashCommandDefinition = { key: "new", - description: "Create new workspace with optional trunk branch. Use -t to specify trunk. Add start message on lines after the command.", + description: + "Create new workspace with optional trunk branch. Use -t to specify trunk. Add start message on lines after the command.", handler: ({ rawInput }): ParsedCommand => { - const { tokens: firstLineTokens, message: remainingLines, hasMultiline } = parseMultilineCommand(rawInput); + const { + tokens: firstLineTokens, + message: remainingLines, + hasMultiline, + } = parseMultilineCommand(rawInput); // Parse flags from first line using minimist const parsed = minimist(firstLineTokens, { @@ -504,9 +514,7 @@ const newCommandDefinition: SlashCommandDefinition = { }); // Check for unknown flags - return undefined workspaceName to open modal - const unknownFlags = firstLineTokens.filter( - (token) => token.startsWith("-") && token !== "-t" - ); + const unknownFlags = firstLineTokens.filter((token) => token.startsWith("-") && token !== "-t"); if (unknownFlags.length > 0) { return { type: "new",