Skip to content

Commit 979de51

Browse files
authored
🤖 Implement /new workspace creation command (#319)
Adds `/new <workspace> [-t <trunk>]` slash command for creating workspaces from chat input. ## Changes - **New slash command**: `/new <workspace-name> [-t <branch>]` creates workspace with optional trunk specification - **Centralized command handlers**: Extracted command logic from ChatInput to `src/utils/chatCommands.ts` (~350 lines) - **Modal integration**: NewWorkspaceModal displays equivalent command string for user education - **Shared creation logic**: Both modal and command paths use same backend API ## Implementation ### Command Syntax ``` /new feature-x # Auto-detect trunk /new feature-x -t develop # Specify trunk /new feature-x\nStart here # With initial message ``` Invalid syntax (unknown flags, extra args, no args) opens the modal for guidance. ### Architecture Created `chatCommands.ts` utility with: - `createNewWorkspace()` - Workspace creation with optional start message - `handleNewCommand()` - Type-safe command handler - `handleCompactCommand()` - Refactored from inline code - `formatNewCommand()` - Command string generation (shared by modal) ChatInput simplified from ~110 lines of inline logic to clean handler calls. ## Testing All 65 slash command tests pass: - 11 new tests for `/new` command variations - No regressions in existing commands - TypeScript type checking passes ## Design Decisions **Anti-pattern avoided**: No `/new-help` command type. Invalid input opens modal instead, providing better UX than text help. **Type safety**: Used `CommandHandlerContext` interface and discriminated unions throughout. **DRY**: Both modal and command share backend API - no logic duplication. _Generated with `cmux`_
1 parent 46878e8 commit 979de51

File tree

10 files changed

+797
-172
lines changed

10 files changed

+797
-172
lines changed

src/components/ChatInput.tsx

Lines changed: 45 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import { useMode } from "@/contexts/ModeContext";
1010
import { ChatToggles } from "./ChatToggles";
1111
import { useSendMessageOptions } from "@/hooks/useSendMessageOptions";
1212
import { getModelKey, getInputKey } from "@/constants/storage";
13-
import { forkWorkspace } from "@/utils/workspaceFork";
13+
import {
14+
handleNewCommand,
15+
handleCompactCommand,
16+
forkWorkspace,
17+
prepareCompactionMessage,
18+
type CommandHandlerContext,
19+
} from "@/utils/chatCommands";
1420
import { ToggleGroup } from "./ToggleGroup";
1521
import { CUSTOM_EVENTS } from "@/constants/events";
1622
import type { UIMode } from "@/types/mode";
@@ -31,10 +37,7 @@ import {
3137
} from "@/utils/imageHandling";
3238

3339
import type { ThinkingLevel } from "@/types/thinking";
34-
import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message";
35-
import type { SendMessageOptions } from "@/types/ipc";
36-
import { applyCompactionOverrides } from "@/utils/messages/compactionOptions";
37-
import { resolveCompactionModel } from "@/utils/messages/compactionModelPreference";
40+
import type { CmuxFrontendMetadata } from "@/types/message";
3841
import { useTelemetry } from "@/hooks/useTelemetry";
3942
import { setTelemetryEnabled } from "@/telemetry";
4043

@@ -159,49 +162,6 @@ export interface ChatInputProps {
159162
}
160163

161164
// Helper function to convert parsed command to display toast
162-
/**
163-
* Prepare compaction message from /compact command
164-
* Returns the actual message text (summarization request), metadata, and options
165-
*/
166-
function prepareCompactionMessage(
167-
command: string,
168-
sendMessageOptions: SendMessageOptions
169-
): {
170-
messageText: string;
171-
metadata: CmuxFrontendMetadata;
172-
options: Partial<SendMessageOptions>;
173-
} {
174-
const parsed = parseCommand(command);
175-
if (parsed?.type !== "compact") {
176-
throw new Error("Not a compact command");
177-
}
178-
179-
const targetWords = parsed.maxOutputTokens ? Math.round(parsed.maxOutputTokens / 1.3) : 2000;
180-
181-
const messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`;
182-
183-
// Handle model preference (sticky globally)
184-
const effectiveModel = resolveCompactionModel(parsed.model);
185-
186-
// Create compaction metadata (will be stored in user message)
187-
const compactData: CompactionRequestData = {
188-
model: effectiveModel,
189-
maxOutputTokens: parsed.maxOutputTokens,
190-
continueMessage: parsed.continueMessage,
191-
};
192-
193-
const metadata: CmuxFrontendMetadata = {
194-
type: "compaction-request",
195-
rawCommand: command,
196-
parsed: compactData,
197-
};
198-
199-
// Apply compaction overrides using shared transformation function
200-
// This same function is used by useResumeManager to ensure consistency
201-
const options = applyCompactionOverrides(sendMessageOptions, compactData);
202-
203-
return { messageText, metadata, options };
204-
}
205165

206166
export const ChatInput: React.FC<ChatInputProps> = ({
207167
workspaceId,
@@ -572,51 +532,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
572532

573533
// Handle /compact command
574534
if (parsed.type === "compact") {
575-
setInput(""); // Clear input immediately
576-
setIsSending(true);
577-
578-
try {
579-
const {
580-
messageText: compactionMessage,
581-
metadata,
582-
options,
583-
} = prepareCompactionMessage(messageText, sendMessageOptions);
584-
585-
const result = await window.api.workspace.sendMessage(workspaceId, compactionMessage, {
586-
...sendMessageOptions,
587-
...options,
588-
cmuxMetadata: metadata,
589-
editMessageId: editingMessage?.id, // Support editing compaction messages
590-
});
535+
const context: CommandHandlerContext = {
536+
workspaceId,
537+
sendMessageOptions,
538+
editMessageId: editingMessage?.id,
539+
setInput,
540+
setIsSending,
541+
setToast,
542+
onCancelEdit,
543+
};
591544

592-
if (!result.success) {
593-
console.error("Failed to initiate compaction:", result.error);
594-
setToast(createErrorToast(result.error));
595-
setInput(messageText); // Restore input on error
596-
} else {
597-
setToast({
598-
id: Date.now().toString(),
599-
type: "success",
600-
message:
601-
metadata.type === "compaction-request" && metadata.parsed.continueMessage
602-
? "Compaction started. Will continue automatically after completion."
603-
: "Compaction started. AI will summarize the conversation.",
604-
});
605-
// Clear editing state on success
606-
if (editingMessage && onCancelEdit) {
607-
onCancelEdit();
608-
}
609-
}
610-
} catch (error) {
611-
console.error("Compaction error:", error);
612-
setToast({
613-
id: Date.now().toString(),
614-
type: "error",
615-
message: error instanceof Error ? error.message : "Failed to start compaction",
616-
});
545+
const result = await handleCompactCommand(parsed, context);
546+
if (!result.clearInput) {
617547
setInput(messageText); // Restore input on error
618-
} finally {
619-
setIsSending(false);
620548
}
621549
return;
622550
}
@@ -667,6 +595,23 @@ export const ChatInput: React.FC<ChatInputProps> = ({
667595
return;
668596
}
669597

598+
// Handle /new command
599+
if (parsed.type === "new") {
600+
const context: CommandHandlerContext = {
601+
workspaceId,
602+
sendMessageOptions,
603+
setInput,
604+
setIsSending,
605+
setToast,
606+
};
607+
608+
const result = await handleNewCommand(parsed, context);
609+
if (!result.clearInput) {
610+
setInput(messageText); // Restore input on error
611+
}
612+
return;
613+
}
614+
670615
// Handle all other commands - show display toast
671616
const commandToast = createCommandToast(parsed);
672617
if (commandToast) {
@@ -719,11 +664,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({
719664
const {
720665
messageText: regeneratedText,
721666
metadata,
722-
options,
723-
} = prepareCompactionMessage(messageText, sendMessageOptions);
667+
sendOptions,
668+
} = prepareCompactionMessage({
669+
workspaceId,
670+
maxOutputTokens: parsed.maxOutputTokens,
671+
continueMessage: parsed.continueMessage,
672+
model: parsed.model,
673+
sendMessageOptions,
674+
});
724675
actualMessageText = regeneratedText;
725676
cmuxMetadata = metadata;
726-
compactionOptions = options;
677+
compactionOptions = sendOptions;
727678
}
728679
}
729680

src/components/CommandPalette.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,27 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
177177
});
178178
}, []);
179179

180+
// Listen for EXECUTE_COMMAND events
181+
useEffect(() => {
182+
const handleExecuteCommand = (e: Event) => {
183+
const customEvent = e as CustomEvent<{ commandId: string }>;
184+
const { commandId } = customEvent.detail;
185+
186+
const action = getActions().find((a) => a.id === commandId);
187+
if (!action) {
188+
console.warn(`Command not found: ${commandId}`);
189+
return;
190+
}
191+
192+
// Run the action directly
193+
void action.run();
194+
addRecent(action.id);
195+
};
196+
197+
window.addEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand);
198+
return () => window.removeEventListener(CUSTOM_EVENTS.EXECUTE_COMMAND, handleExecuteCommand);
199+
}, [getActions, startPrompt, addRecent]);
200+
180201
const handlePromptValue = useCallback(
181202
(value: string) => {
182203
let nextInitial: string | null = null;

src/components/NewWorkspaceModal.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useId, useState } from "react";
22
import styled from "@emotion/styled";
33
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";
44
import { TooltipWrapper, Tooltip } from "./Tooltip";
5+
import { formatNewCommand } from "@/utils/chatCommands";
56

67
const FormGroup = styled.div`
78
margin-bottom: 20px;
@@ -61,6 +62,29 @@ const UnderlinedLabel = styled.span`
6162
cursor: help;
6263
`;
6364

65+
const CommandDisplay = styled.div`
66+
margin-top: 20px;
67+
padding: 12px;
68+
background: #1e1e1e;
69+
border: 1px solid #3e3e42;
70+
border-radius: 4px;
71+
font-family: "Menlo", "Monaco", "Courier New", monospace;
72+
font-size: 13px;
73+
color: #d4d4d4;
74+
white-space: pre-wrap;
75+
word-break: break-all;
76+
`;
77+
78+
const CommandLabel = styled.div`
79+
font-size: 12px;
80+
color: #888;
81+
margin-bottom: 8px;
82+
font-family:
83+
system-ui,
84+
-apple-system,
85+
sans-serif;
86+
`;
87+
6488
interface NewWorkspaceModalProps {
6589
isOpen: boolean;
6690
projectName: string;
@@ -236,6 +260,15 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
236260
</InfoCode>
237261
</ModalInfo>
238262

263+
{branchName.trim() && (
264+
<div>
265+
<CommandLabel>Equivalent command:</CommandLabel>
266+
<CommandDisplay>
267+
{formatNewCommand(branchName.trim(), trunkBranch.trim() || undefined)}
268+
</CommandDisplay>
269+
</div>
270+
)}
271+
239272
<ModalActions>
240273
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
241274
Cancel

src/constants/events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export const CUSTOM_EVENTS = {
4040
* Detail: { workspaceId: string, projectPath: string, projectName: string, workspacePath: string, branch: string }
4141
*/
4242
WORKSPACE_FORK_SWITCH: "cmux:workspaceForkSwitch",
43+
44+
/**
45+
* Event to execute a command from the command palette
46+
* Detail: { commandId: string }
47+
*/
48+
EXECUTE_COMMAND: "cmux:executeCommand",
4349
} as const;
4450

4551
/**

0 commit comments

Comments
 (0)