diff --git a/docs/cli.mdx b/docs/cli.mdx index 139bf339c0..1b774601f8 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -102,3 +102,25 @@ Print the version and git commit: mux --version # v0.8.4 (abc123) ``` + +## Debug Environment Variables + +These environment variables help diagnose issues with LLM requests and responses. + +| Variable | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_DEBUG_LLM_REQUEST` | Set to `1` to log the complete LLM request (system prompt, messages, tools, provider options) as formatted JSON to the debug logs. Useful for diagnosing prompt issues. | + +Example usage: + +```bash +MUX_DEBUG_LLM_REQUEST=1 mux run "Hello world" +``` + +The output includes: + +- `systemMessage`: The full system prompt sent to the model +- `messages`: All conversation messages in the request +- `tools`: Tool definitions with descriptions and input schemas +- `providerOptions`: Provider-specific options (thinking level, etc.) +- `mode`, `thinkingLevel`, `maxOutputTokens`, `toolPolicy` diff --git a/docs/docs.json b/docs/docs.json index 96be48abd8..155381e307 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,6 +46,7 @@ "init-hooks" ] }, + "plan-mode", "vscode-extension", "models", { diff --git a/docs/plan-mode.mdx b/docs/plan-mode.mdx new file mode 100644 index 0000000000..35ee7f2e47 --- /dev/null +++ b/docs/plan-mode.mdx @@ -0,0 +1,124 @@ +--- +title: Plan Mode +description: Review and collaborate on plans before execution +--- + +Plan mode lets you review and refine the agent's approach before any code changes happen. Instead of diving straight into implementation, the agent writes a plan to a file, proposes it for your review, and waits for approval. + +## How It Works + +1. **Toggle to Plan Mode**: Press `Cmd+Shift+M` (Mac) or `Ctrl+Shift+M` (Windows/Linux), or use the mode switcher in the UI. + +2. **Agent Writes Plan**: In plan mode, all file edit tools (`file_edit_*`) are restricted to only modify the plan file. The agent can still read any file in the workspace to gather context. + +3. **Propose for Review**: When ready, the agent calls `propose_plan` to present the plan in the chat UI with rendered markdown. + +4. **Edit Externally**: Click the **Edit** button on the latest plan to open it in your preferred editor (nvim, VS Code, etc.). Your changes are automatically detected. + +5. **Iterate or Execute**: Provide feedback in chat, or switch to Exec mode (`Cmd+Shift+M`) to implement the plan. + +## External Edit Detection + +When you edit the plan file externally and send a message, mux automatically detects the changes and informs the agent with a diff. This uses a timestamp-based polling approach: + +1. **State Tracking**: When `propose_plan` runs, it records the plan file's content and modification time. +2. **Change Detection**: Before each LLM query, mux checks if the file's mtime has changed. +3. **Diff Injection**: If modified, mux computes a diff and injects it into the context so the agent sees exactly what changed. + +This means you can make edits in your preferred editor, return to mux, send a message, and the agent will incorporate your changes. + +## Plan File Location + +Plans are stored in a dedicated directory: + +``` +~/.mux/plans/.md +``` + +The file is created when the agent first writes a plan and persists across sessions. + +## UI Features + +The `propose_plan` tool call in chat includes: + +- **Rendered Markdown**: View the plan with proper formatting. +- **Edit Button**: Opens the plan file in your external editor (latest plan only). +- **Copy Button**: Copy plan content to clipboard. +- **Show Text/Markdown Toggle**: Switch between rendered and raw views. +- **Start Here**: Replace chat history with this plan as context (useful for long sessions). + +## Workflow Example + +``` +User: "Add user authentication to the app" + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent reads codebase, writes plan │ +│ to ~/.mux/plans/.md │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent calls propose_plan │ +│ (plan displayed in chat) │ +└─────────────────────────────────────┘ + │ + ├─────────────────────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ User provides │ │ User clicks "Edit" │ +│ feedback in chat │ │ → edits in nvim/vscode │ +└─────────────────────┘ └─────────────────────────┘ + │ │ + │ │ (external edits) + │ ▼ + │ ┌─────────────────────────┐ + │ │ User sends message │ + │ │ → mux detects changes │ + │ │ → diff injected │ + │ └─────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ Agent revises plan based on feedback │ +│ (cycles back to propose_plan) │ +└─────────────────────────────────────────────────────────┘ + │ + │ (when satisfied) + ▼ +┌─────────────────────────────────────┐ +│ User switches to Exec mode │ +│ (Cmd+Shift+M) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent implements the plan │ +└─────────────────────────────────────┘ +``` + +## Customizing Plan Mode Behavior + +Use [scoped instructions](/instruction-files) to customize how the agent behaves in plan mode: + +```markdown +## Mode: Plan + +When planning: + +- Focus on goals and trade-offs +- Propose alternatives with pros/cons +- Attach LoC estimates to each approach +``` + +## CLI Usage + +Plan mode is also available via the CLI: + +```bash +mux run --mode plan "Design a caching strategy for the API" +``` + +See [CLI documentation](/cli) for more options. diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index f8e3a051ff..7316d8ff4d 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -445,6 +445,17 @@ const AIViewInner: React.FC = ({ )?.historyId : undefined; + // Find the ID of the latest propose_plan tool call for external edit detection + // Only the latest plan should fetch fresh content from disk + let latestProposePlanId: string | null = null; + for (let i = mergedMessages.length - 1; i >= 0; i--) { + const msg = mergedMessages[i]; + if (msg.type === "tool" && msg.toolName === "propose_plan") { + latestProposePlanId = msg.id; + break; + } + } + if (loading) { return (
= ({ workspaceId={workspaceId} isCompacting={isCompacting} onReviewNote={handleReviewNote} + isLatestProposePlan={ + msg.type === "tool" && + msg.toolName === "propose_plan" && + msg.id === latestProposePlanId + } />
{isAtCutoff && ( diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 88a04edd53..85cf91c935 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -31,6 +31,8 @@ import { import { handleNewCommand, handleCompactCommand, + handlePlanShowCommand, + handlePlanOpenCommand, forkWorkspace, prepareCompactionMessage, executeCompaction, @@ -933,6 +935,35 @@ export const ChatInput: React.FC = (props) => { return; } + // Handle /plan command + if (parsed.type === "plan-show" || parsed.type === "plan-open") { + if (!api) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Not connected to server", + }); + return; + } + const context: CommandHandlerContext = { + api: api, + workspaceId: props.workspaceId, + sendMessageOptions, + setInput, + setImageAttachments, + setIsSending, + setToast, + }; + + const handler = + parsed.type === "plan-show" ? handlePlanShowCommand : handlePlanOpenCommand; + const result = await handler(context); + if (!result.clearInput) { + setInput(messageText); // Restore input on error + } + return; + } + // Handle all other commands - show display toast const commandToast = createCommandToast(parsed); if (commandToast) { diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index c1d0eb4e7a..8d9d2848ef 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -527,8 +527,8 @@ function createDraftSettingsHarness( }> ) { const state = { - runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode), - defaultRuntimeMode: initial?.defaultRuntimeMode ?? ("worktree" as RuntimeMode), + runtimeMode: initial?.runtimeMode ?? "local", + defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree", sshHost: initial?.sshHost ?? "", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index fca1ad1726..c13adc8e18 100644 --- a/src/browser/components/Messages/MessageRenderer.tsx +++ b/src/browser/components/Messages/MessageRenderer.tsx @@ -8,6 +8,8 @@ import { ReasoningMessage } from "./ReasoningMessage"; import { StreamErrorMessage } from "./StreamErrorMessage"; import { HistoryHiddenMessage } from "./HistoryHiddenMessage"; import { InitMessage } from "./InitMessage"; +import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; +import { removeEphemeralMessage } from "@/browser/stores/WorkspaceStore"; interface MessageRendererProps { message: DisplayedMessage; @@ -18,11 +20,21 @@ interface MessageRendererProps { isCompacting?: boolean; /** Handler for adding review notes from inline diffs */ onReviewNote?: (data: ReviewNoteData) => void; + /** Whether this message is the latest propose_plan tool call (for external edit detection) */ + isLatestProposePlan?: boolean; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates export const MessageRenderer = React.memo( - ({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => { + ({ + message, + className, + onEditUserMessage, + workspaceId, + isCompacting, + onReviewNote, + isLatestProposePlan, + }) => { // Route based on message type switch (message.type) { case "user": @@ -50,6 +62,7 @@ export const MessageRenderer = React.memo( className={className} workspaceId={workspaceId} onReviewNote={onReviewNote} + isLatestProposePlan={isLatestProposePlan} /> ); case "reasoning": @@ -60,6 +73,22 @@ export const MessageRenderer = React.memo( return ; case "workspace-init": return ; + case "plan-display": + return ( + { + if (workspaceId) { + removeEphemeralMessage(workspaceId, message.historyId); + } + }} + className={className} + /> + ); default: console.error("don't know how to render message", message); return null; diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 1de41ffb36..319bb6e93f 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -43,6 +43,8 @@ interface ToolMessageProps { workspaceId?: string; /** Handler for adding review notes from inline diffs */ onReviewNote?: (data: ReviewNoteData) => void; + /** Whether this is the latest propose_plan in the conversation */ + isLatestProposePlan?: boolean; } // Type guards using Zod schemas for single source of truth @@ -116,6 +118,7 @@ export const ToolMessage: React.FC = ({ className, workspaceId, onReviewNote, + isLatestProposePlan, }) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { @@ -193,6 +196,7 @@ export const ToolMessage: React.FC = ({ result={message.result as ProposePlanToolResult | undefined} status={message.status} workspaceId={workspaceId} + isLatest={isLatestProposePlan} /> ); diff --git a/src/browser/components/tools/ProposePlanToolCall.tsx b/src/browser/components/tools/ProposePlanToolCall.tsx index b5b4edcbf7..41562dad42 100644 --- a/src/browser/components/tools/ProposePlanToolCall.tsx +++ b/src/browser/components/tools/ProposePlanToolCall.tsx @@ -1,5 +1,10 @@ -import React, { useState } from "react"; -import type { ProposePlanToolArgs, ProposePlanToolResult } from "@/common/types/tools"; +import React, { useState, useEffect } from "react"; +import type { + ProposePlanToolResult, + ProposePlanToolError, + LegacyProposePlanToolArgs, + LegacyProposePlanToolResult, +} from "@/common/types/tools"; import { ToolContainer, ToolHeader, @@ -15,29 +20,187 @@ import { useStartHere } from "@/browser/hooks/useStartHere"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { cn } from "@/common/lib/utils"; +import { useAPI } from "@/browser/contexts/API"; + +/** + * Check if the result is from the new file-based propose_plan tool + */ +function isNewProposePlanResult(result: unknown): result is ProposePlanToolResult { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === true && + "planContent" in result && + "planPath" in result + ); +} + +/** + * Check if the result is an error from propose_plan tool + */ +function isProposePlanError(result: unknown): result is ProposePlanToolError { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === false && + "error" in result + ); +} + +/** + * Check if the result is from the legacy propose_plan tool + */ +function isLegacyProposePlanResult(result: unknown): result is LegacyProposePlanToolResult { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === true && + "title" in result && + "plan" in result + ); +} + +/** + * Check if args are from the legacy propose_plan tool + */ +function isLegacyProposePlanArgs(args: unknown): args is LegacyProposePlanToolArgs { + return args !== null && typeof args === "object" && "title" in args && "plan" in args; +} interface ProposePlanToolCallProps { - args: ProposePlanToolArgs; - result?: ProposePlanToolResult; + args: Record; + result?: unknown; status?: ToolStatus; workspaceId?: string; + /** Whether this is the latest propose_plan in the conversation */ + isLatest?: boolean; + /** When true, renders as ephemeral preview (no tool wrapper, shows close button) */ + isEphemeralPreview?: boolean; + /** Callback when user closes ephemeral preview */ + onClose?: () => void; + /** Direct content for ephemeral preview (bypasses args/result extraction) */ + content?: string; + /** Direct path for ephemeral preview */ + path?: string; + /** Optional className for the outer wrapper */ + className?: string; } -export const ProposePlanToolCall: React.FC = ({ - args, - result: _result, - status = "pending", - workspaceId, -}) => { +export const ProposePlanToolCall: React.FC = (props) => { + const { + args, + result, + status = "pending", + workspaceId, + isLatest, + isEphemeralPreview, + onClose, + content: directContent, + path: directPath, + className, + } = props; const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default const [showRaw, setShowRaw] = useState(false); + const { api } = useAPI(); + + // Fresh content from disk for the latest plan (external edit detection) + // Skip for ephemeral previews which already have fresh content + const [freshContent, setFreshContent] = useState(null); + const [freshPath, setFreshPath] = useState(null); + + // Check if an editor is available (hides Edit button if not) + const [canEdit, setCanEdit] = useState(false); + + useEffect(() => { + if (!api) return; + void api.general.canOpenInEditor().then((result) => { + setCanEdit(result.method !== "none"); + }); + }, [api]); + + // Fetch fresh plan content for the latest plan + // Re-fetches on mount and when window regains focus (after user edits in external editor) + useEffect(() => { + if (isEphemeralPreview || !isLatest || !workspaceId || !api) return; + + const fetchPlan = async () => { + try { + const res = await api.workspace.getPlanContent({ workspaceId }); + if (res.success) { + setFreshContent(res.data.content); + setFreshPath(res.data.path); + } + } catch { + // Fetch failed, use cached content from result + } + }; + + // Fetch immediately on mount + void fetchPlan(); + + // Re-fetch when window regains focus (user returns from external editor) + const handleFocus = () => { + void fetchPlan(); + }; + window.addEventListener("focus", handleFocus); + + return () => { + window.removeEventListener("focus", handleFocus); + }; + }, [api, workspaceId, isLatest, isEphemeralPreview]); + + // Determine plan content and title based on result type (prefer result over args) + // For ephemeral previews, use direct content/path props + // For the latest plan, prefer fresh content from disk (external edit support) + let planContent: string; + let planTitle: string; + let planPath: string | undefined; + let errorMessage: string | undefined; + + if (isEphemeralPreview && directContent !== undefined) { + // Ephemeral preview mode: use direct props + planContent = directContent; + planPath = directPath; + const titleMatch = /^#\s+(.+)$/m.exec(directContent); + planTitle = titleMatch ? titleMatch[1] : "Plan"; + } else if (isLatest && freshContent !== null) { + planContent = freshContent; + planPath = freshPath ?? undefined; + // Extract title from first markdown heading or use filename + const titleMatch = /^#\s+(.+)$/m.exec(freshContent); + planTitle = titleMatch ? titleMatch[1] : (planPath?.split("/").pop() ?? "Plan"); + } else if (isNewProposePlanResult(result)) { + planContent = result.planContent; + planPath = result.planPath; + // Extract title from first markdown heading or use filename + const titleMatch = /^#\s+(.+)$/m.exec(result.planContent); + planTitle = titleMatch ? titleMatch[1] : (planPath.split("/").pop() ?? "Plan"); + } else if (isLegacyProposePlanResult(result)) { + planContent = result.plan; + planTitle = result.title; + } else if (isProposePlanError(result)) { + // Error from backend (e.g., plan file missing or empty) + planContent = ""; + planTitle = "Plan Error"; + errorMessage = result.error; + } else if (isLegacyProposePlanArgs(args)) { + // Fallback to args for backwards compatibility + planContent = args.plan; + planTitle = args.title; + } else { + // No valid plan data available + planContent = ""; + planTitle = "Plan"; + } // Format: Title as H1 + plan content for "Start Here" functionality - const startHereContent = `# ${args.title}\n\n${args.plan}`; + const startHereContent = `# ${planTitle}\n\n${planContent}`; const { openModal, buttonLabel, - buttonEmoji, disabled: startHereDisabled, modal, } = useStartHere( @@ -49,105 +212,164 @@ export const ProposePlanToolCall: React.FC = ({ // Copy to clipboard with feedback const { copied, copyToClipboard } = useCopyToClipboard(); - const [isHovered, setIsHovered] = useState(false); + const handleOpenInEditor = async () => { + if (!planPath || !api) { + return; + } + try { + const result = await api.general.openInEditor({ + filePath: planPath, + workspaceId, + }); + if (!result.success) { + console.error("Failed to open plan in editor:", result.error); + return; + } + // If opened in embedded terminal (server mode), open terminal window + if ( + result.data.openedInEmbeddedTerminal && + result.data.workspaceId && + result.data.sessionId + ) { + const isBrowser = !window.api; + if (isBrowser) { + const url = `/terminal.html?workspaceId=${encodeURIComponent(result.data.workspaceId)}&sessionId=${encodeURIComponent(result.data.sessionId)}`; + window.open( + url, + `terminal-editor-${result.data.sessionId}`, + "width=1000,height=600,popup=yes" + ); + } + } + } catch (err) { + console.error("openInEditor threw error:", err); + } + }; const controlButtonClasses = "px-2 py-1 text-[10px] font-mono rounded-sm cursor-pointer transition-all duration-150 active:translate-y-px"; const statusDisplay = getStatusDisplay(status); - return ( - - - - propose_plan - {statusDisplay} - - - {expanded && ( - -
-
-
-
📋
-
- {args.title} -
-
-
- {workspaceId && ( - - - - - - Replace all chat history with this plan - - - )} + // Shared plan UI content (used in both tool call and ephemeral preview modes) + const planUI = ( +
+
+
+
📋
+
{planTitle}
+ {isEphemeralPreview && ( +
preview only
+ )} +
+
+ {/* Edit button: show for ephemeral preview OR latest tool call, only if editor available */} + {(isEphemeralPreview ?? isLatest) && planPath && api && canEdit && ( + + + + Open plan in external editor + + )} + {/* Start Here button: only for tool calls, not ephemeral previews */} + {!isEphemeralPreview && workspaceId && ( + + -
-
- - {showRaw ? ( -
-                {args.plan}
-              
- ) : ( -
- -
+ + + Replace all chat history with this plan + + + )} + + + {/* Close button: only for ephemeral previews */} + {isEphemeralPreview && onClose && ( + + )} +
+
- {status === "completed" && ( -
- Respond with revisions or switch to Exec mode ( - - {formatKeybind(KEYBINDS.TOGGLE_MODE)} - - ) and ask to implement. -
- )} -
- + {errorMessage ? ( +
{errorMessage}
+ ) : showRaw ? ( +
+          {planContent}
+        
+ ) : ( +
+ +
+ )} + + {/* Completion guidance: only for completed tool calls without errors, not ephemeral previews */} + {!isEphemeralPreview && status === "completed" && !errorMessage && ( +
+ Respond with revisions or switch to Exec mode ( + {formatKeybind(KEYBINDS.TOGGLE_MODE)}) + and ask to implement. +
)} +
+ ); + + // Ephemeral preview mode: simple wrapper without tool container + if (isEphemeralPreview) { + return
{planUI}
; + } + + // Tool call mode: full tool container with header + return ( + + + + propose_plan + {statusDisplay} + + + {expanded && {planUI}} {modal} diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 2c8da3799f..f7270ca2a8 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -3,7 +3,7 @@ import { useMode } from "@/browser/contexts/ModeContext"; import { usePersistedState } from "./usePersistedState"; import { getDefaultModel } from "./useModelLRU"; import { migrateGatewayModel, useGateway, isProviderSupported } from "./useGatewayModels"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import { getModelKey } from "@/common/constants/storage"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { UIMode } from "@/common/types/mode"; @@ -35,6 +35,8 @@ function applyGatewayTransform(modelId: string, gateway: GatewayState): string { /** * Construct SendMessageOptions from raw values * Shared logic for both hook and non-hook versions + * + * Note: Plan mode instructions are handled by the backend (has access to plan file path) */ function constructSendMessageOptions( mode: UIMode, @@ -44,8 +46,6 @@ function constructSendMessageOptions( fallbackModel: string, gateway: GatewayState ): SendMessageOptions { - const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined; - // Ensure model is always a valid string (defensive against corrupted localStorage) const rawModel = typeof preferredModel === "string" && preferredModel ? preferredModel : fallbackModel; @@ -64,7 +64,6 @@ function constructSendMessageOptions( model, mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions, providerOptions, }; } diff --git a/src/browser/hooks/useTerminalSession.ts b/src/browser/hooks/useTerminalSession.ts index 65cc4949eb..8a228cdbfd 100644 --- a/src/browser/hooks/useTerminalSession.ts +++ b/src/browser/hooks/useTerminalSession.ts @@ -1,14 +1,19 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useAPI } from "@/browser/contexts/API"; import type { TerminalSession } from "@/common/types/terminal"; /** - * Hook to manage terminal IPC session lifecycle + * Hook to manage terminal IPC session lifecycle. + * + * Supports two modes: + * 1. Create new session: when existingSessionId is undefined, creates a new PTY session + * 2. Reattach to existing session: when existingSessionId is provided (e.g., from openInEditor), + * subscribes to that session without creating a new one */ export function useTerminalSession( workspaceId: string, - _existingSessionId: string | undefined, // Reserved for future use (session reload support) + existingSessionId: string | undefined, enabled: boolean, terminalSize?: { cols: number; rows: number } | null, onOutput?: (data: string) => void, @@ -20,6 +25,10 @@ export function useTerminalSession( const [error, setError] = useState(null); const [shouldInit, setShouldInit] = useState(false); + // Track whether we created the session (vs reattaching to existing) + // Used to determine if we should close the session on cleanup + const createdSessionRef = useRef(false); + // Watch for terminalSize to become available useEffect(() => { if (enabled && terminalSize && !shouldInit) { @@ -28,31 +37,40 @@ export function useTerminalSession( }, [enabled, terminalSize, shouldInit]); // Create terminal session and subscribe to IPC events - // Only depends on workspaceId and shouldInit, NOT terminalSize + // Only depends on workspaceId, existingSessionId and shouldInit, NOT terminalSize useEffect(() => { if (!shouldInit || !terminalSize || !api) { return; } let mounted = true; - let createdSessionId: string | null = null; // Track session ID in closure + let targetSessionId: string | null = null; const cleanupFns: Array<() => void> = []; const initSession = async () => { try { - // Create terminal session with current terminal size - const session: TerminalSession = await api.terminal.create({ - workspaceId, - cols: terminalSize.cols, - rows: terminalSize.rows, - }); - - if (!mounted) { - return; + if (existingSessionId) { + // Reattach to existing session (e.g., from openInEditor) + // The session was already created by the backend with initialCommand + targetSessionId = existingSessionId; + createdSessionRef.current = false; + } else { + // Create new terminal session with current terminal size + const session: TerminalSession = await api.terminal.create({ + workspaceId, + cols: terminalSize.cols, + rows: terminalSize.rows, + }); + + if (!mounted) { + return; + } + + targetSessionId = session.sessionId; + createdSessionRef.current = true; } - createdSessionId = session.sessionId; // Store in closure - setSessionId(session.sessionId); + setSessionId(targetSessionId); const abortController = new AbortController(); const { signal } = abortController; @@ -62,7 +80,7 @@ export function useTerminalSession( (async () => { try { const iterator = await api.terminal.onOutput( - { sessionId: session.sessionId }, + { sessionId: targetSessionId }, { signal } ); for await (const data of iterator) { @@ -79,10 +97,7 @@ export function useTerminalSession( // Subscribe to exit events via ORPC async iterator (async () => { try { - const iterator = await api.terminal.onExit( - { sessionId: session.sessionId }, - { signal } - ); + const iterator = await api.terminal.onExit({ sessionId: targetSessionId }, { signal }); for await (const code of iterator) { if (!mounted) break; setConnected(false); @@ -100,9 +115,9 @@ export function useTerminalSession( setConnected(true); setError(null); } catch (err) { - console.error("[Terminal] Failed to create terminal session:", err); + console.error("[Terminal] Failed to initialize terminal session:", err); if (mounted) { - setError(err instanceof Error ? err.message : "Failed to create terminal"); + setError(err instanceof Error ? err.message : "Failed to initialize terminal"); } } }; @@ -115,17 +130,17 @@ export function useTerminalSession( // Unsubscribe from IPC events cleanupFns.forEach((fn) => fn()); - // Close terminal session using the closure variable - // This ensures we close the session created by this specific effect run - if (createdSessionId) { - void api?.terminal.close({ sessionId: createdSessionId }); + // Only close the session if WE created it (not for reattached sessions) + // Reattached sessions (e.g., from openInEditor) should persist when the window closes + if (targetSessionId && createdSessionRef.current) { + void api?.terminal.close({ sessionId: targetSessionId }); } // Reset init flag so a new session can be created if workspace changes setShouldInit(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, shouldInit]); // DO NOT include terminalSize - changes should not recreate session + }, [workspaceId, existingSessionId, shouldInit, api]); // DO NOT include terminalSize - changes should not recreate session // Send input to terminal const sendInput = useCallback( diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index b5e67b3e5c..4b7e4bd735 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -530,6 +530,14 @@ export class WorkspaceStore { return this.aggregators.get(workspaceId); } + /** + * Bump state for a workspace to trigger React re-renders. + * Used by addEphemeralMessage for frontend-only messages. + */ + bumpState(workspaceId: string): void { + this.states.bump(workspaceId); + } + /** * Get current TODO list for a workspace. * Returns empty array if workspace doesn't exist or has no TODOs. @@ -1127,6 +1135,32 @@ export function useWorkspaceAggregator( return store.getAggregator(workspaceId); } +/** + * Add an ephemeral message to a workspace and trigger a re-render. + * Used for displaying frontend-only messages like /plan output. + */ +export function addEphemeralMessage(workspaceId: string, message: MuxMessage): void { + const store = getStoreInstance(); + const aggregator = store.getAggregator(workspaceId); + if (aggregator) { + aggregator.addMessage(message); + store.bumpState(workspaceId); + } +} + +/** + * Remove an ephemeral message from a workspace and trigger a re-render. + * Used for dismissing frontend-only messages like /plan output. + */ +export function removeEphemeralMessage(workspaceId: string, messageId: string): void { + const store = getStoreInstance(); + const aggregator = store.getAggregator(workspaceId); + if (aggregator) { + aggregator.removeMessage(messageId); + store.bumpState(workspaceId); + } +} + /** * Hook for usage metadata (instant, no tokenization). * Updates immediately when usage metadata arrives from API responses. diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 9e4fd2c8fa..49b68629cc 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, test, beforeEach } from "bun:test"; +import { describe, expect, test, beforeEach, mock } from "bun:test"; import type { SendMessageOptions } from "@/common/orpc/types"; -import { parseRuntimeString, prepareCompactionMessage } from "./chatCommands"; +import { + parseRuntimeString, + prepareCompactionMessage, + handlePlanShowCommand, + handlePlanOpenCommand, +} from "./chatCommands"; +import type { CommandHandlerContext } from "./chatCommands"; // Simple mock for localStorage to satisfy resolveCompactionModel beforeEach(() => { @@ -179,3 +185,161 @@ describe("prepareCompactionMessage", () => { expect(metadata.parsed.continueMessage?.imageParts).toHaveLength(1); }); }); + +describe("handlePlanShowCommand", () => { + const createMockContext = ( + getPlanContentResult: + | { success: true; data: { content: string; path: string } } + | { success: false; error: string } + ): CommandHandlerContext => { + const setInput = mock(() => undefined); + const setToast = mock(() => undefined); + + return { + workspaceId: "test-workspace-id", + setInput, + setToast, + api: { + workspace: { + getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), + }, + general: {}, + } as unknown as CommandHandlerContext["api"], + // Required fields for CommandHandlerContext + sendMessageOptions: { + model: "anthropic:claude-3-5-sonnet", + thinkingLevel: "off", + toolPolicy: [], + mode: "exec", + }, + setImageAttachments: mock(() => undefined), + setIsSending: mock(() => undefined), + }; + }; + + test("shows error toast when no plan exists", async () => { + const context = createMockContext({ success: false, error: "No plan found" }); + + const result = await handlePlanShowCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No plan found for this workspace", + }) + ); + }); + + test("clears input when plan is found", async () => { + const context = createMockContext({ + success: true, + data: { content: "# My Plan\n\nStep 1", path: "/path/to/plan.md" }, + }); + + const result = await handlePlanShowCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(false); + expect(context.setInput).toHaveBeenCalledWith(""); + expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ + workspaceId: "test-workspace-id", + }); + }); +}); + +describe("handlePlanOpenCommand", () => { + const createMockContext = ( + getPlanContentResult: + | { success: true; data: { content: string; path: string } } + | { success: false; error: string }, + openInEditorResult?: + | { success: true; data: { openedInEmbeddedTerminal: boolean } } + | { success: false; error: string } + ): CommandHandlerContext => { + const setInput = mock(() => undefined); + const setToast = mock(() => undefined); + + return { + workspaceId: "test-workspace-id", + setInput, + setToast, + api: { + workspace: { + getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), + }, + general: { + openInEditor: mock(() => + Promise.resolve( + openInEditorResult ?? { success: true, data: { openedInEmbeddedTerminal: false } } + ) + ), + }, + } as unknown as CommandHandlerContext["api"], + // Required fields for CommandHandlerContext + sendMessageOptions: { + model: "anthropic:claude-3-5-sonnet", + thinkingLevel: "off", + toolPolicy: [], + mode: "exec", + }, + setImageAttachments: mock(() => undefined), + setIsSending: mock(() => undefined), + }; + }; + + test("shows error toast when no plan exists", async () => { + const context = createMockContext({ success: false, error: "No plan found" }); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No plan found for this workspace", + }) + ); + // Should not attempt to open editor + expect(context.api.general.openInEditor).not.toHaveBeenCalled(); + }); + + test("opens plan in editor when plan exists", async () => { + const context = createMockContext( + { success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }, + { success: true, data: { openedInEmbeddedTerminal: false } } + ); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(context.setInput).toHaveBeenCalledWith(""); + expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ + workspaceId: "test-workspace-id", + }); + expect(context.api.general.openInEditor).toHaveBeenCalledWith({ + filePath: "/path/to/plan.md", + workspaceId: "test-workspace-id", + }); + }); + + test("shows error toast when editor fails to open", async () => { + const context = createMockContext( + { success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }, + { success: false, error: "No editor configured" } + ); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No editor configured", + }) + ); + }); +}); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 089c3bb6df..e550db5214 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -38,6 +38,7 @@ import { import { createCommandToast } from "@/browser/components/ChatInputToasts"; import { trackCommandUsed, trackProviderConfigured } from "@/common/telemetry"; +import { addEphemeralMessage } from "@/browser/stores/WorkspaceStore"; export interface ForkOptions { client: RouterClient; @@ -271,6 +272,18 @@ export async function processSlashCommand( ...context, workspaceId: context.workspaceId, } as CommandHandlerContext); + case "plan-show": + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handlePlanShowCommand({ + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); + case "plan-open": + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handlePlanOpenCommand({ + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); } } @@ -898,6 +911,104 @@ export async function handleCompactCommand( } } +// ============================================================================ +// Plan Command Handlers +// ============================================================================ + +export async function handlePlanShowCommand( + context: CommandHandlerContext +): Promise { + const { api, workspaceId, setInput, setToast } = context; + + setInput(""); + + const result = await api.workspace.getPlanContent({ workspaceId }); + if (!result.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }); + return { clearInput: true, toastShown: true }; + } + + // Create ephemeral plan-display message (not persisted to history) + // Uses addEphemeralMessage to properly trigger React re-render via store bump + // Use a very high historySequence so it appears at the end of the chat + const planMessage = { + id: `plan-display-${Date.now()}`, + role: "assistant" as const, + parts: [{ type: "text" as const, text: result.data.content }], + metadata: { + historySequence: Number.MAX_SAFE_INTEGER, // Appear at end of chat + muxMetadata: { type: "plan-display" as const, path: result.data.path }, + }, + }; + addEphemeralMessage(workspaceId, planMessage); + + trackCommandUsed("plan"); + return { clearInput: true, toastShown: false }; +} + +export async function handlePlanOpenCommand( + context: CommandHandlerContext +): Promise { + const { api, workspaceId, setInput, setToast } = context; + + setInput(""); + + // First get the plan path + const planResult = await api.workspace.getPlanContent({ workspaceId }); + if (!planResult.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }); + return { clearInput: true, toastShown: true }; + } + + // Open in editor + const openResult = await api.general.openInEditor({ + filePath: planResult.data.path, + workspaceId, + }); + + if (!openResult.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: openResult.error ?? "Failed to open editor", + }); + return { clearInput: true, toastShown: true }; + } + + // If opened in embedded terminal (server mode), open terminal window + if ( + openResult.data.openedInEmbeddedTerminal && + openResult.data.workspaceId && + openResult.data.sessionId + ) { + const isBrowser = !window.api; + if (isBrowser) { + const url = `/terminal.html?workspaceId=${encodeURIComponent(openResult.data.workspaceId)}&sessionId=${encodeURIComponent(openResult.data.sessionId)}`; + window.open( + url, + `terminal-editor-${openResult.data.sessionId}`, + "width=1000,height=600,popup=yes" + ); + } + } + + trackCommandUsed("plan"); + setToast({ + id: Date.now().toString(), + type: "success", + message: "Opened plan in editor", + }); + return { clearInput: true, toastShown: true }; +} + // ============================================================================ // Utilities // ============================================================================ diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 68768fb55f..8ab6e9ce47 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -264,6 +264,16 @@ export class StreamingMessageAggregator { this.invalidateCache(); } + /** + * Remove a message from the aggregator. + * Used for dismissing ephemeral messages like /plan output. + */ + removeMessage(messageId: string): void { + if (this.messages.delete(messageId)) { + this.invalidateCache(); + } + } + /** * Load historical messages in batch, preserving their historySequence numbers. * This is more efficient than calling addMessage() repeatedly. @@ -828,12 +838,36 @@ export class StreamingMessageAggregator { getDisplayedMessages(): DisplayedMessage[] { if (!this.cachedDisplayedMessages) { const displayedMessages: DisplayedMessage[] = []; + const allMessages = this.getAllMessages(); + + for (const message of allMessages) { + // Skip synthetic messages - they're for model context only, not UI display + if (message.metadata?.synthetic) { + continue; + } - for (const message of this.getAllMessages()) { const baseTimestamp = message.metadata?.timestamp; // Get historySequence from backend (required field) const historySequence = message.metadata?.historySequence ?? 0; + // Check for plan-display messages (ephemeral /plan output) + const muxMeta = message.metadata?.muxMetadata; + if (muxMeta?.type === "plan-display") { + const content = message.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + displayedMessages.push({ + type: "plan-display", + id: message.id, + historyId: message.id, + content, + path: muxMeta.path, + historySequence, + }); + continue; + } + if (message.role === "user") { // User messages: combine all text parts into single block, extract images const content = message.parts diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 32d40640fe..3bd9cb2caf 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -12,7 +12,8 @@ export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean { msg.type === "user" || msg.type === "stream-error" || msg.type === "history-hidden" || - msg.type === "workspace-init" + msg.type === "workspace-init" || + msg.type === "plan-display" ) return false; diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index 1a5339a026..e2a57c9e82 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -6,6 +6,7 @@ import { addInterruptedSentinel, injectModeTransition, filterEmptyAssistantMessages, + injectFileChangeNotifications, } from "./modelMessageTransform"; import type { MuxMessage } from "@/common/types/message"; @@ -1027,3 +1028,98 @@ describe("filterEmptyAssistantMessages", () => { expect(result2[1].metadata?.partial).toBe(true); }); }); + +describe("injectFileChangeNotifications", () => { + it("should return messages unchanged when no file attachments provided", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const result = injectFileChangeNotifications(messages, undefined); + expect(result).toEqual(messages); + + const result2 = injectFileChangeNotifications(messages, []); + expect(result2).toEqual(messages); + }); + + it("should append synthetic user message with file change notification", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Fix this code" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "I'll fix it" }], + metadata: { timestamp: 2000 }, + }, + ]; + + const changedFiles = [ + { + type: "edited_text_file" as const, + filename: "src/app.ts", + snippet: "@@ -10,3 +10,3 @@\n-const x = 1\n+const x = 2", + }, + ]; + + const result = injectFileChangeNotifications(messages, changedFiles); + + expect(result.length).toBe(3); + expect(result[0]).toEqual(messages[0]); + expect(result[1]).toEqual(messages[1]); + + const syntheticMsg = result[2]; + expect(syntheticMsg.role).toBe("user"); + expect(syntheticMsg.metadata?.synthetic).toBe(true); + expect(syntheticMsg.id).toMatch(/^file-change-/); + expect(syntheticMsg.parts[0]).toMatchObject({ + type: "text", + }); + const text = (syntheticMsg.parts[0] as { type: "text"; text: string }).text; + expect(text).toContain(""); + expect(text).toContain("src/app.ts was modified"); + expect(text).toContain("@@ -10,3 +10,3 @@"); + }); + + it("should handle multiple file changes", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const changedFiles = [ + { + type: "edited_text_file" as const, + filename: "src/foo.ts", + snippet: "diff1", + }, + { + type: "edited_text_file" as const, + filename: "src/bar.ts", + snippet: "diff2", + }, + ]; + + const result = injectFileChangeNotifications(messages, changedFiles); + + expect(result.length).toBe(2); + const text = (result[1].parts[0] as { type: "text"; text: string }).text; + expect(text).toContain("src/foo.ts was modified"); + expect(text).toContain("src/bar.ts was modified"); + expect(text).toContain("diff1"); + expect(text).toContain("diff2"); + }); +}); diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index c0ae3bd1d1..ce549be3aa 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -5,6 +5,7 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage } from "ai"; import type { MuxMessage } from "@/common/types/message"; +import type { EditedFileAttachment } from "@/node/services/agentSession"; /** * Filter out assistant messages that are empty or only contain reasoning parts. @@ -198,6 +199,45 @@ export function injectModeTransition( return result; } +/** + * Inject file change notifications as a synthetic user message. + * When files are modified externally (by user or linter), append a notification at the end + * so the model is aware of changes without busting the system message cache. + * + * @param messages The conversation history + * @param changedFileAttachments Files that were modified externally + * @returns Messages with file change notification appended if any files changed + */ +export function injectFileChangeNotifications( + messages: MuxMessage[], + changedFileAttachments?: EditedFileAttachment[] +): MuxMessage[] { + if (!changedFileAttachments || changedFileAttachments.length === 0) { + return messages; + } + + const notice = changedFileAttachments + .map( + (att) => + `Note: ${att.filename} was modified, either by the user or by a linter.\n` + + `This change was intentional, so make sure to take it into account as you proceed ` + + `(i.e., don't revert it unless the user asks you to). Here are the relevant changes:\n${att.snippet}` + ) + .join("\n\n"); + + const syntheticMessage: MuxMessage = { + id: `file-change-${Date.now()}`, + role: "user", + parts: [{ type: "text", text: `\n${notice}\n` }], + metadata: { + timestamp: Date.now(), + synthetic: true, + }, + }; + + return [...messages, syntheticMessage]; +} + /** * Filter out assistant messages that only contain reasoning parts (no text or tool parts). * Anthropic API rejects messages that have reasoning but no actual content. diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 646643efaf..cd22d38a0a 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,5 +1,5 @@ import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import { toGatewayModel, migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; @@ -57,8 +57,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Get provider options const providerOptions = getProviderOptions(); - // Plan mode system instructions - const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined; + // Plan mode instructions are now handled by the backend (has access to plan file path) // Enforce thinking policy (gpt-5-pro → high only) const effectiveThinkingLevel = enforceThinkingPolicy(model, thinkingLevel); @@ -68,7 +67,6 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend thinkingLevel: effectiveThinkingLevel, toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions, providerOptions, }; } diff --git a/src/browser/utils/slashCommands/parser.test.ts b/src/browser/utils/slashCommands/parser.test.ts index 099c98cbec..571a8ac9f0 100644 --- a/src/browser/utils/slashCommands/parser.test.ts +++ b/src/browser/utils/slashCommands/parser.test.ts @@ -208,3 +208,21 @@ it("should preserve start message with runtime flag when no workspace name", () startMessage: "Deploy to staging", }); }); + +describe("plan commands", () => { + it("should parse /plan as plan-show", () => { + expectParse("/plan", { type: "plan-show" }); + }); + + it("should parse /plan open as plan-open", () => { + expectParse("/plan open", { type: "plan-open" }); + }); + + it("should return unknown-command for invalid /plan subcommand", () => { + expectParse("/plan invalid", { + type: "unknown-command", + command: "plan", + subcommand: "invalid", + }); + }); +}); diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index d87d3dbf0d..0eaad5d14a 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -456,6 +456,26 @@ const vimCommandDefinition: SlashCommandDefinition = { }, }; +const planOpenCommandDefinition: SlashCommandDefinition = { + key: "open", + description: "Open plan in external editor", + appendSpace: false, + handler: (): ParsedCommand => ({ type: "plan-open" }), +}; + +const planCommandDefinition: SlashCommandDefinition = { + key: "plan", + description: "Show or edit the current plan", + appendSpace: false, + handler: ({ cleanRemainingTokens }): ParsedCommand => { + if (cleanRemainingTokens.length > 0) { + return { type: "unknown-command", command: "plan", subcommand: cleanRemainingTokens[0] }; + } + return { type: "plan-show" }; + }, + children: [planOpenCommandDefinition], +}; + const forkCommandDefinition: SlashCommandDefinition = { key: "fork", description: @@ -640,6 +660,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ compactCommandDefinition, modelCommandDefinition, providersCommandDefinition, + planCommandDefinition, forkCommandDefinition, newCommandDefinition, diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index bbf3a17be8..cf32f9a45e 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -33,6 +33,8 @@ export type ParsedCommand = | { type: "mcp-edit"; name: string; command: string } | { type: "mcp-remove"; name: string } | { type: "mcp-open" } + | { type: "plan-show" } + | { type: "plan-open" } | { type: "unknown-command"; command: string; subcommand?: string } | null; diff --git a/src/cli/run.ts b/src/cli/run.ts index b53bba92fe..9b903a857d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -35,7 +35,7 @@ import { } from "@/common/orpc/types"; import { defaultModel } from "@/common/utils/ai/models"; import { ensureProvidersConfig } from "@/common/utils/providers/ensureProvidersConfig"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { RuntimeConfig } from "@/common/types/runtime"; import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; @@ -338,8 +338,9 @@ async function main(): Promise { const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({ model, thinkingLevel, + mode: cliMode, toolPolicy: modeToToolPolicy(cliMode), - additionalSystemInstructions: cliMode === "plan" ? PLAN_MODE_INSTRUCTION : undefined, + // Plan mode instructions are handled by the backend (has access to plan file path) }); const liveEvents: WorkspaceChatMessage[] = []; diff --git a/src/common/constants/paths.ts b/src/common/constants/paths.ts index f4ca660b57..389d8c6a60 100644 --- a/src/common/constants/paths.ts +++ b/src/common/constants/paths.ts @@ -77,6 +77,17 @@ export function getMuxSessionsDir(rootDir?: string): string { return join(root, "sessions"); } +/** + * Get the directory where plan files are stored. + * Example: ~/.mux/plans/workspace-id.md + * + * @param rootDir - Optional root directory (defaults to getMuxHome()) + */ +export function getMuxPlansDir(rootDir?: string): string { + const root = rootDir ?? getMuxHome(); + return join(root, "plans"); +} + /** * Get the main configuration file path. * diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 1163c9a80d..184c517b28 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -294,6 +294,20 @@ export const workspace = { ), }, }, + /** + * Get the current plan file content for a workspace. + * Used by UI to refresh plan display when file is edited externally. + */ + getPlanContent: { + input: z.object({ workspaceId: z.string() }), + output: ResultSchema( + z.object({ + content: z.string(), + path: z.string(), + }), + z.string() + ), + }, }; export type WorkspaceSendMessageOutput = z.infer; @@ -418,6 +432,55 @@ export const general = { }), output: eventIterator(z.object({ tick: z.number(), timestamp: z.number() })), }, + /** + * Open a file in the user's preferred external editor. + * Uses $VISUAL -> $EDITOR -> 'code' fallback chain. + * + * In desktop mode: Opens native terminal or spawns GUI editor directly. + * In server mode: Creates embedded terminal session with the editor command. + * + * When openedInEmbeddedTerminal is true, the frontend should open/focus + * the terminal panel for the specified workspace to show the editor. + */ + openInEditor: { + input: z.object({ + filePath: z.string(), + /** Required for server mode to create embedded terminal */ + workspaceId: z.string().optional(), + }), + output: ResultSchema( + z.object({ + /** True if opened in embedded terminal (server mode with $EDITOR) */ + openedInEmbeddedTerminal: z.boolean(), + /** Workspace ID if embedded terminal was used */ + workspaceId: z.string().optional(), + /** Terminal session ID if embedded terminal was used */ + sessionId: z.string().optional(), + }), + z.string() + ), + }, + /** + * Check if an external editor is available for opening files. + * Used to conditionally show/hide Edit buttons in the UI. + * + * Discovery priority: + * 1. $VISUAL - User's explicit GUI editor preference + * 2. $EDITOR - User's explicit editor preference + * 3. GUI fallbacks: cursor, code, zed, subl (discovered via `which`) + * 4. Terminal fallbacks: nvim, vim, vi, nano, emacs (discovered via `which`) + */ + canOpenInEditor: { + input: z.void(), + output: z.object({ + /** How the editor was discovered */ + method: z.enum(["visual", "editor", "gui-fallback", "terminal-fallback", "none"]), + /** The actual editor command that will be used (undefined when method="none") */ + editor: z.string().optional(), + /** True if the editor requires a terminal window (terminal-fallback only) */ + requiresTerminal: z.boolean().optional(), + }), + }, }; // Menu events (main→renderer notifications) diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index 1a1f06eacf..2b46196d2c 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -40,6 +40,7 @@ const TelemetryCommandTypeSchema = z.enum([ "vim", "model", "mode", + "plan", "providers", ]); diff --git a/src/common/orpc/schemas/terminal.ts b/src/common/orpc/schemas/terminal.ts index e6ca2fbd3f..62cd0e84d4 100644 --- a/src/common/orpc/schemas/terminal.ts +++ b/src/common/orpc/schemas/terminal.ts @@ -11,6 +11,8 @@ export const TerminalCreateParamsSchema = z.object({ workspaceId: z.string(), cols: z.number(), rows: z.number(), + /** Optional command to run immediately after terminal creation */ + initialCommand: z.string().optional(), }); export const TerminalResizeParamsSchema = z.object({ diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index 9fed2ea75b..a266983c5e 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -144,6 +144,7 @@ export type TelemetryCommandType = | "vim" | "model" | "mode" + | "plan" | "providers"; /** diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/common/types/message.ts b/src/common/types/message.ts index fc402fc5dd..78617aba5b 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -83,6 +83,10 @@ export type MuxFrontendMetadata = MuxFrontendMetadataBase & rawCommand: string; // The original /compact command as typed by user (for display) parsed: CompactionRequestData; } + | { + type: "plan-display"; // Ephemeral plan display from /plan command + path: string; + } | { type: "normal"; // Regular messages } @@ -237,6 +241,14 @@ export type DisplayedMessage = lines: string[]; // Accumulated output lines (stderr prefixed with "ERROR:") exitCode: number | null; // Final exit code (null while running) timestamp: number; + } + | { + type: "plan-display"; // Ephemeral plan display from /plan command + id: string; // Display ID for UI/React keys + historyId: string; // Original MuxMessage ID (same as id for ephemeral messages) + content: string; // Plan markdown content + path: string; // Path to the plan file + historySequence: number; // Global ordering across all messages }; export interface QueuedMessage { diff --git a/src/common/types/mode.ts b/src/common/types/mode.ts index 7880b8b79a..85fb8d7980 100644 --- a/src/common/types/mode.ts +++ b/src/common/types/mode.ts @@ -1,5 +1,8 @@ +import { z } from "zod"; + /** * UI Mode types */ -export type UIMode = "plan" | "exec"; +export const UIModeSchema = z.enum(["plan", "exec"]); +export type UIMode = z.infer; diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 5f805193e2..06c44792fe 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -3,6 +3,9 @@ * These types are used by both the tool implementations and UI components */ +import type { z } from "zod"; +import type { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + // Bash Tool Types export interface BashToolArgs { script: string; @@ -166,12 +169,36 @@ export type FileEditToolArgs = | FileEditInsertToolArgs; // Propose Plan Tool Types -export interface ProposePlanToolArgs { +// Args derived from schema +export type ProposePlanToolArgs = z.infer; + +// Result type for new file-based propose_plan tool +export interface ProposePlanToolResult { + success: true; + planPath: string; + planContent: string; + message: string; +} + +// Error result when plan file not found +export interface ProposePlanToolError { + success: false; + error: string; +} + +/** + * @deprecated Legacy args type for backwards compatibility with old propose_plan tool calls. + * Old sessions may have tool calls with title + plan args stored in chat history. + */ +export interface LegacyProposePlanToolArgs { title: string; plan: string; } -export interface ProposePlanToolResult { +/** + * @deprecated Legacy result type for backwards compatibility. + */ +export interface LegacyProposePlanToolResult { success: true; title: string; plan: string; diff --git a/src/common/utils/planStorage.ts b/src/common/utils/planStorage.ts new file mode 100644 index 0000000000..446f1f2d6f --- /dev/null +++ b/src/common/utils/planStorage.ts @@ -0,0 +1,31 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { getMuxPlansDir } from "@/common/constants/paths"; + +/** + * Get the plan file path for a workspace. + * Plan files are stored in a dedicated directory: ~/.mux/plans/{workspaceId}.md + */ +export function getPlanFilePath(workspaceId: string): string { + return join(getMuxPlansDir(), `${workspaceId}.md`); +} + +/** + * Read the plan file content for a workspace. + * Returns null if the file doesn't exist or can't be read. + */ +export function readPlanFile(workspaceId: string): string | null { + const planPath = getPlanFilePath(workspaceId); + try { + return readFileSync(planPath, "utf-8"); + } catch { + return null; + } +} + +/** + * Check if a plan file exists for a workspace. + */ +export function planFileExists(workspaceId: string): boolean { + return existsSync(getPlanFilePath(workspaceId)); +} diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index f53ad142f0..f37cead657 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -161,23 +161,10 @@ export const TOOL_DEFINITIONS = { }, propose_plan: { description: - "Propose a plan before taking action. The plan should be complete but minimal - cover what needs to be decided or understood, nothing more. Use this tool to get approval before proceeding with implementation.", - schema: z.object({ - title: z - .string() - .describe("A short, descriptive title for the plan (e.g., 'Add User Authentication')"), - plan: z - .string() - .describe( - "Implementation plan in markdown (start at h2 level). " + - "Scale the detail to match the task complexity: for straightforward changes, briefly state what and why; " + - "for complex changes, explain approach, key decisions, risks/tradeoffs; " + - "for uncertain changes, clarify options and what needs user input. " + - "When presenting options, always provide your recommendation for the overall best option for the user. " + - "For highly complex concepts, use mermaid diagrams where they'd clarify better than text. " + - "Cover what's necessary to understand and approve the approach. Omit obvious details or ceremony." - ), - }), + "Signal that your plan is complete and ready for user approval. " + + "This tool reads the plan from the plan file you wrote. " + + "You must write your plan to the plan file before calling this tool.", + schema: z.object({}), }, todo_write: { description: diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index d688ebbd2d..0bdf428cf4 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -15,6 +15,8 @@ import { log } from "@/node/services/log"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { UIMode } from "@/common/types/mode"; +import type { FileState } from "@/node/services/agentSession"; /** * Configuration for tools that need runtime context @@ -24,6 +26,8 @@ export interface ToolConfiguration { cwd: string; /** Runtime environment for executing commands and file operations */ runtime: Runtime; + /** Local runtime for plan file operations (bypasses SSH for plan files which are always local) */ + localRuntime?: Runtime; /** Environment secrets to inject (optional) */ secrets?: Record; /** MUX_ environment variables (MUX_PROJECT_PATH, MUX_RUNTIME) - set from init hook env */ @@ -36,8 +40,14 @@ export interface ToolConfiguration { overflow_policy?: "truncate" | "tmpfile"; /** Background process manager for bash tool (optional, AI-only) */ backgroundProcessManager?: BackgroundProcessManager; - /** Workspace ID for tracking background processes (optional for token estimation) */ + /** Current UI mode (plan or exec) - used for plan file path enforcement */ + mode?: UIMode; + /** Plan file path - only this file can be edited in plan mode */ + planFilePath?: string; + /** Workspace ID for tracking background processes and plan storage */ workspaceId?: string; + /** Callback to record file state for external edit detection (plan files) */ + recordFileState?: (filePath: string, state: FileState) => void; } /** diff --git a/src/common/utils/ui/modeUtils.ts b/src/common/utils/ui/modeUtils.ts index 1e40f3eac7..451ddfc587 100644 --- a/src/common/utils/ui/modeUtils.ts +++ b/src/common/utils/ui/modeUtils.ts @@ -2,30 +2,51 @@ import type { UIMode } from "@/common/types/mode"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; /** - * System instruction for Plan Mode - * Extracted as constant to avoid duplication across sendMessageOptions builders + * Generate the system instruction for Plan Mode with file path context. + * The plan file path tells the agent where to write their plan. + */ +export function getPlanModeInstruction(planFilePath: string, planExists: boolean): string { + const fileStatus = planExists + ? `A plan file already exists at ${planFilePath}. You can read it and make incremental edits using the file_edit_* tools.` + : `No plan file exists yet. You should create your plan at ${planFilePath} using the file_edit_* tools.`; + + return `You are in Plan Mode. ${fileStatus} + +You should build your plan incrementally by writing to or editing this file. +NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. + +When you have finished writing your plan and are ready for user approval, call the propose_plan tool. +Do not make other edits in plan mode. You may have tools like bash but only use them for read-only operations. + +If the user suggests that you should make edits to other files, ask them to switch to Exec mode first! +`; +} + +/** + * Legacy constant for backwards compatibility. + * @deprecated Use getPlanModeInstruction(planFilePath, planExists) instead */ export const PLAN_MODE_INSTRUCTION = `You are in Plan Mode. You may use tools to research and understand the task, but you MUST call the propose_plan tool with your findings before completing your response. Do not provide a text response without calling propose_plan. Do not make edits in plan mode. You may have tools like bash but only use them for read-only operations. This rule on edits applies beyond files. Do not create side effects of any kind in plan mode. -If the user suggests that you should make edits, ask them to changes modes first! +If the user suggests that you should make edits, ask them to changes modes first! `; /** - * Get the tool policy for a given UI mode + * Get the tool policy for a given UI mode. + * In plan mode, file_edit_* tools remain enabled (agent needs them to write plan file), + * but strict path enforcement in file_edit_operation.ts restricts edits to only the plan file. */ export function modeToToolPolicy(mode: UIMode): ToolPolicy { if (mode === "plan") { return [ - { regex_match: "file_edit_.*", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, + // file_edit_* stays enabled - agent needs it to write plan file + // Path restriction is enforced in file_edit_operation.ts ]; } // exec mode - return [ - { regex_match: "propose_plan", action: "disable" }, - { regex_match: "file_edit_.*", action: "enable" }, - ]; + return [{ regex_match: "propose_plan", action: "disable" }]; } diff --git a/src/constants/slashCommands.ts b/src/constants/slashCommands.ts index 5e6a294315..3e81dadd7e 100644 --- a/src/constants/slashCommands.ts +++ b/src/constants/slashCommands.ts @@ -12,4 +12,6 @@ export const WORKSPACE_ONLY_COMMANDS: ReadonlySet = new Set([ "compact", "fork", "new", + "plan-show", + "plan-open", ]); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b7ee647e9a..5947759882 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,3 +1,4 @@ +import { spawn } from "child_process"; import { os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; @@ -13,6 +14,8 @@ import type { } from "@/common/orpc/types"; import { createAuthMiddleware } from "./authMiddleware"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +import { getPlanFilePath, readPlanFile } from "@/common/utils/planStorage"; +import { findAvailableCommand, GUI_EDITORS, TERMINAL_EDITORS } from "@/node/utils/commandDiscovery"; export const router = (authToken?: string) => { const t = os.$context().use(createAuthMiddleware(authToken)); @@ -122,6 +125,143 @@ export const router = (authToken?: string) => { } } }), + openInEditor: t + .input(schemas.general.openInEditor.input) + .output(schemas.general.openInEditor.output) + .handler(async ({ context, input }) => { + try { + const visual = process.env.VISUAL; + const editor = process.env.EDITOR; + const isDesktop = context.terminalService.isDesktopMode(); + + if (visual) { + // $VISUAL is set - GUI editor, spawn directly (works in both modes) + const child = spawn(visual, [input.filePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } + + if (editor) { + // $EDITOR is set - terminal editor + if (isDesktop) { + // Desktop mode: open native terminal + await context.terminalService.openNativeWithCommand( + `${editor} "${input.filePath}"` + ); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } else { + // Server mode: create embedded terminal with the command + if (!input.workspaceId) { + return { + success: false as const, + error: "workspaceId required for opening editor in server mode", + }; + } + const session = await context.terminalService.create({ + workspaceId: input.workspaceId, + cols: 120, + rows: 30, + initialCommand: `${editor} "${input.filePath}"`, + }); + return { + success: true as const, + data: { + openedInEmbeddedTerminal: true, + workspaceId: input.workspaceId, + sessionId: session.sessionId, + }, + }; + } + } + + // Fallback: discover available GUI editors + const guiEditor = await findAvailableCommand([...GUI_EDITORS]); + if (guiEditor) { + const child = spawn(guiEditor, [input.filePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } + + // Fallback: discover available terminal editors + const terminalEditor = await findAvailableCommand([...TERMINAL_EDITORS]); + if (terminalEditor) { + if (isDesktop) { + // Desktop mode: open native terminal with editor + await context.terminalService.openNativeWithCommand( + `${terminalEditor} "${input.filePath}"` + ); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } else { + // Server mode: create embedded terminal with editor + if (!input.workspaceId) { + return { + success: false as const, + error: "workspaceId required for opening editor in server mode", + }; + } + const session = await context.terminalService.create({ + workspaceId: input.workspaceId, + cols: 120, + rows: 30, + initialCommand: `${terminalEditor} "${input.filePath}"`, + }); + return { + success: true as const, + data: { + openedInEmbeddedTerminal: true, + workspaceId: input.workspaceId, + sessionId: session.sessionId, + }, + }; + } + } + + return { success: false as const, error: "No editor available" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false as const, error: `Failed to open editor: ${message}` }; + } + }), + canOpenInEditor: t + .input(schemas.general.canOpenInEditor.input) + .output(schemas.general.canOpenInEditor.output) + .handler(async () => { + // Check $VISUAL first + const visual = process.env.VISUAL; + if (visual) { + return { method: "visual" as const, editor: visual }; + } + + // Check $EDITOR + const editor = process.env.EDITOR; + if (editor) { + return { method: "editor" as const, editor }; + } + + // Discover GUI editors + const guiEditor = await findAvailableCommand([...GUI_EDITORS]); + if (guiEditor) { + return { method: "gui-fallback" as const, editor: guiEditor }; + } + + // Discover terminal editors + const terminalEditor = await findAvailableCommand([...TERMINAL_EDITORS]); + if (terminalEditor) { + return { + method: "terminal-fallback" as const, + editor: terminalEditor, + requiresTerminal: true, + }; + } + + return { method: "none" as const }; + }), }, projects: { list: t @@ -534,6 +674,17 @@ export const router = (authToken?: string) => { } }), }, + getPlanContent: t + .input(schemas.workspace.getPlanContent.input) + .output(schemas.workspace.getPlanContent.output) + .handler(({ input }) => { + const planPath = getPlanFilePath(input.workspaceId); + const content = readPlanFile(input.workspaceId); + if (content === null) { + return { success: false as const, error: `Plan file not found at ${planPath}` }; + } + return { success: true as const, data: { content, path: planPath } }; + }), }, window: { setTitle: t diff --git a/src/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts new file mode 100644 index 0000000000..a7161497e8 --- /dev/null +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, writeFile, rm, utimes, stat, readFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { FileState, EditedFileAttachment } from "./agentSession"; +import { computeDiff } from "@/node/utils/diff"; + +/** + * Tests for external file change detection in AgentSession. + * + * Pattern: timestamp-based polling with diff injection + * 1. Track file state (content + mtime) when reading/writing files + * 2. Poll before each LLM query to detect external modifications + * 3. Compute diff and inject as context attachment if changed + * + * These tests verify the core detection algorithm by testing + * the isolated logic without requiring full AgentSession integration. + */ + +/** + * Extracted core logic from AgentSession.getChangedFileAttachments + * for isolated unit testing. This mirrors the actual implementation. + */ +async function getChangedFileAttachments( + readFileState: Map, + readFileFn: (path: string) => Promise<{ content: string; mtime: number }> +): Promise { + const checks = Array.from(readFileState.entries()).map( + async ([filePath, state]): Promise => { + try { + const { content: currentContent, mtime: currentMtime } = await readFileFn(filePath); + if (currentMtime <= state.timestamp) return null; // No change + + const diff = computeDiff(state.content, currentContent); + if (!diff) return null; // Content identical despite mtime change + + // Update stored state + readFileState.set(filePath, { content: currentContent, timestamp: currentMtime }); + + return { + type: "edited_text_file", + filename: filePath, + snippet: diff, + }; + } catch { + // File deleted or inaccessible, skip + return null; + } + } + ); + + const results = await Promise.all(checks); + return results.filter((r): r is EditedFileAttachment => r !== null); +} + +describe("AgentSession change detection", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "mux-change-detection-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + // Helper to get file info using Node.js fs API + async function getFileInfo(path: string): Promise<{ content: string; mtime: number }> { + const fileStat = await stat(path); + const content = await readFile(path, "utf-8"); + return { + content, + mtime: fileStat.mtimeMs, + }; + } + + describe("recordFileState and getChangedFileAttachments", () => { + it("should detect no changes when file unchanged", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan\n\n## Step 1\n\nDo something"; + + await writeFile(testFile, content); + const { mtime } = await getFileInfo(testFile); + + // Record initial state + readFileState.set(testFile, { content, timestamp: mtime }); + + // Check for changes - should be empty since file is unchanged + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should detect changes when file content modified externally", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "# Plan\n\n## Step 1\n\nDo something"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + // Record initial state + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Simulate external edit (wait briefly to ensure mtime changes) + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "# Plan\n\n## Step 1\n\nDo something better\n\n## Step 2\n\nNew step"; + await writeFile(testFile, modifiedContent); + + // Update mtime to be in the future to simulate external edit + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // Check for changes - should detect the modification + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(1); + expect(attachments[0].type).toBe("edited_text_file"); + expect(attachments[0].filename).toBe(testFile); + expect(attachments[0].snippet).toContain("Do something better"); + expect(attachments[0].snippet).toContain("Step 2"); + expect(attachments[0].snippet).toContain("New step"); + }); + + it("should update stored state after detecting change", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "# Original"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Modify file + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "# Modified"; + await writeFile(testFile, modifiedContent); + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // First detection + const firstCheck = await getChangedFileAttachments(readFileState, getFileInfo); + expect(firstCheck).toHaveLength(1); + + // Second check without further changes - should be empty + // because state was updated after first detection + const secondCheck = await getChangedFileAttachments(readFileState, getFileInfo); + expect(secondCheck).toHaveLength(0); + }); + + it("should return empty when file deleted", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan"; + + await writeFile(testFile, content); + const { mtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content, timestamp: mtime }); + + // Delete the file + await rm(testFile); + + // Should gracefully handle deleted file + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should detect changes across multiple tracked files", async () => { + const readFileState = new Map(); + const file1 = join(tmpDir, "plan.md"); + const file2 = join(tmpDir, "notes.md"); + const file3 = join(tmpDir, "unchanged.md"); + + await writeFile(file1, "Original 1"); + await writeFile(file2, "Original 2"); + await writeFile(file3, "Original 3"); + + const { mtime: mtime1 } = await getFileInfo(file1); + const { mtime: mtime2 } = await getFileInfo(file2); + const { mtime: mtime3 } = await getFileInfo(file3); + + readFileState.set(file1, { content: "Original 1", timestamp: mtime1 }); + readFileState.set(file2, { content: "Original 2", timestamp: mtime2 }); + readFileState.set(file3, { content: "Original 3", timestamp: mtime3 }); + + // Modify only files 1 and 2 + await new Promise((resolve) => setTimeout(resolve, 10)); + await writeFile(file1, "Modified 1"); + await writeFile(file2, "Modified 2"); + const newMtime = Date.now() + 1000; + await utimes(file1, newMtime / 1000, newMtime / 1000); + await utimes(file2, newMtime / 1000, newMtime / 1000); + + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(2); + const filenames = attachments.map((a) => a.filename); + expect(filenames).toContain(file1); + expect(filenames).toContain(file2); + expect(filenames).not.toContain(file3); + }); + + it("should ignore mtime change when content identical (touch scenario)", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan unchanged"; + + await writeFile(testFile, content); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content, timestamp: originalMtime }); + + // Update only mtime (like 'touch' command) without changing content + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // Should not report change since content is identical + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should produce valid unified diff format", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "line 1\nline 2\nline 3\nline 4\nline 5"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Modify middle line + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "line 1\nline 2\nmodified line 3\nline 4\nline 5"; + await writeFile(testFile, modifiedContent); + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(1); + const diff = attachments[0].snippet; + + // Verify unified diff format + expect(diff).toContain("@@"); + expect(diff).toContain("-line 3"); + expect(diff).toContain("+modified line 3"); + }); + }); + + describe("computeDiff utility", () => { + it("should return null for identical content", () => { + const content = "# Plan\n\nContent here"; + expect(computeDiff(content, content)).toBeNull(); + }); + + it("should return diff for modified content", () => { + const old = "line 1\nline 2\nline 3"; + const modified = "line 1\nmodified line 2\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("-line 2"); + expect(diff).toContain("+modified line 2"); + }); + + it("should handle added lines", () => { + const old = "line 1\nline 2"; + const modified = "line 1\nline 2\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("+line 3"); + }); + + it("should handle removed lines", () => { + const old = "line 1\nline 2\nline 3"; + const modified = "line 1\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("-line 2"); + }); + + it("should handle empty to non-empty", () => { + const diff = computeDiff("", "new content"); + expect(diff).not.toBeNull(); + expect(diff).toContain("+new content"); + }); + + it("should handle non-empty to empty", () => { + const diff = computeDiff("old content", ""); + expect(diff).not.toBeNull(); + expect(diff).toContain("-old content"); + }); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 5d8ab4387f..ca80b30969 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1,6 +1,7 @@ import assert from "@/common/utils/assert"; import { EventEmitter } from "events"; import * as path from "path"; +import { stat, readFile } from "fs/promises"; import { PlatformPaths } from "@/common/utils/paths"; import { createMuxMessage } from "@/common/types/message"; import type { Config } from "@/node/config"; @@ -29,6 +30,25 @@ import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; +import { computeDiff } from "@/node/utils/diff"; + +/** + * Tracked file state for detecting external edits. + * Uses timestamp-based polling with diff injection. + */ +export interface FileState { + content: string; + timestamp: number; // mtime in ms +} + +/** + * Attachment for files that were edited externally between messages. + */ +export interface EditedFileAttachment { + type: "edited_text_file"; + filename: string; + snippet: string; // diff of changes +} // Type guard for compaction request metadata interface CompactionRequestMetadata { @@ -83,6 +103,12 @@ export class AgentSession { private readonly messageQueue = new MessageQueue(); private readonly compactionHandler: CompactionHandler; + /** + * Tracked file state for detecting external edits. + * Key: absolute file path, Value: last known content and mtime. + */ + private readonly readFileState = new Map(); + constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); const { @@ -492,12 +518,18 @@ export class AgentSession { return Err(createUnknownSendMessageError(historyResult.error)); } + // Check for external file edits (timestamp-based polling) + const changedFileAttachments = await this.getChangedFileAttachments(); + // Enforce thinking policy for the specified model (single source of truth) // This ensures model-specific requirements are met regardless of where the request originates const effectiveThinkingLevel = options?.thinkingLevel ? enforceThinkingPolicy(modelString, options.thinkingLevel) : undefined; + // Bind recordFileState to this session for the propose_plan tool + const recordFileState = this.recordFileState.bind(this); + return this.aiService.streamMessage( historyResult.data, this.workspaceId, @@ -508,7 +540,9 @@ export class AgentSession { options?.additionalSystemInstructions, options?.maxOutputTokens, options?.providerOptions, - options?.mode + options?.mode, + recordFileState, + changedFileAttachments.length > 0 ? changedFileAttachments : undefined ); } @@ -678,6 +712,49 @@ export class AgentSession { } } + /** + * Record file state for change detection. + * Called by tools (e.g., propose_plan) after reading/writing files. + */ + recordFileState(filePath: string, state: FileState): void { + this.readFileState.set(filePath, state); + } + + /** + * Check tracked files for external modifications. + * Returns attachments for files that changed since last recorded state. + * Uses timestamp-based polling with diff injection. + */ + async getChangedFileAttachments(): Promise { + const checks = Array.from(this.readFileState.entries()).map( + async ([filePath, state]): Promise => { + try { + const currentMtime = (await stat(filePath)).mtimeMs; + if (currentMtime <= state.timestamp) return null; // No change + + const currentContent = await readFile(filePath, "utf-8"); + const diff = computeDiff(state.content, currentContent); + if (!diff) return null; // Content identical despite mtime change + + // Update stored state + this.readFileState.set(filePath, { content: currentContent, timestamp: currentMtime }); + + return { + type: "edited_text_file", + filename: filePath, + snippet: diff, + }; + } catch { + // File deleted or inaccessible, skip + return null; + } + } + ); + + const results = await Promise.all(checks); + return results.filter((r): r is EditedFileAttachment => r !== null); + } + private assertNotDisposed(operation: string): void { assert(!this.disposed, `AgentSession.${operation} called after dispose`); } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 297d5e8847..e4353f67c6 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -23,10 +23,12 @@ import type { InitStateManager } from "./initStateManager"; import type { SendMessageError } from "@/common/types/errors"; import { getToolsForModel } from "@/common/utils/tools/tools"; import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; import { secretsToRecord } from "@/common/types/secrets"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { FileState, EditedFileAttachment } from "@/node/services/agentSession"; import { log } from "./log"; import { transformModelMessages, @@ -34,6 +36,7 @@ import { addInterruptedSentinel, filterEmptyAssistantMessages, injectModeTransition, + injectFileChangeNotifications, } from "@/browser/utils/messages/modelMessageTransform"; import { applyCacheControl } from "@/common/utils/ai/cacheStrategy"; import type { HistoryService } from "./historyService"; @@ -52,6 +55,9 @@ import type { import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { MockScenarioPlayer } from "./mock/mockScenarioPlayer"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; +import { getPlanFilePath, planFileExists } from "@/common/utils/planStorage"; +import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; +import type { UIMode } from "@/common/types/mode"; // Export a standalone version of getToolsForModel for use in backend @@ -833,6 +839,8 @@ export class AIService extends EventEmitter { * @param maxOutputTokens Optional maximum tokens for model output * @param muxProviderOptions Optional provider-specific options * @param mode Optional mode name - affects system message via Mode: sections in AGENTS.md + * @param recordFileState Optional callback to record file state for external edit detection + * @param changedFileAttachments Optional attachments for files that were edited externally * @returns Promise that resolves when streaming completes or fails */ async streamMessage( @@ -845,7 +853,9 @@ export class AIService extends EventEmitter { additionalSystemInstructions?: string, maxOutputTokens?: number, muxProviderOptions?: MuxProviderOptions, - mode?: string + mode?: string, + recordFileState?: (filePath: string, state: FileState) => void, + changedFileAttachments?: EditedFileAttachment[] ): Promise> { try { if (this.mockModeEnabled && this.mockScenarioPlayer) { @@ -937,9 +947,15 @@ export class AIService extends EventEmitter { toolNamesForSentinel ); + // Inject file change notifications as user messages (preserves system message cache) + const messagesWithFileChanges = injectFileChangeNotifications( + messagesWithModeContext, + changedFileAttachments + ); + // Apply centralized tool-output redaction BEFORE converting to provider ModelMessages // This keeps the persisted/UI history intact while trimming heavy fields for the request - const redactedForProvider = applyToolOutputRedaction(messagesWithModeContext); + const redactedForProvider = applyToolOutputRedaction(messagesWithFileChanges); log.debug_obj(`${workspaceId}/2a_redacted_messages.json`, redactedForProvider); // Sanitize tool inputs to ensure they are valid objects (not strings or arrays) @@ -1007,13 +1023,25 @@ export class AIService extends EventEmitter { ? await this.mcpServerManager.listServers(metadata.projectPath) : undefined; + // Construct plan mode instruction if in plan mode + // This is done backend-side because we have access to the plan file path + let effectiveAdditionalInstructions = additionalSystemInstructions; + if (mode === "plan") { + const planFilePath = getPlanFilePath(workspaceId); + const planExists = planFileExists(workspaceId); + const planModeInstruction = getPlanModeInstruction(planFilePath, planExists); + effectiveAdditionalInstructions = additionalSystemInstructions + ? `${planModeInstruction}\n\n${additionalSystemInstructions}` + : planModeInstruction; + } + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, runtime, workspacePath, mode, - additionalSystemInstructions, + effectiveAdditionalInstructions, modelString, mcpServers ); @@ -1057,6 +1085,10 @@ export class AIService extends EventEmitter { { cwd: workspacePath, runtime, + // Plan files are always local - create a local runtime for plan file I/O + // even when the workspace uses SSH runtime + localRuntime: + mode === "plan" ? new LocalRuntime(workspacePath, runtimeTempDir) : undefined, secrets: secretsToRecord(projectSecrets), muxEnv: getMuxEnv( metadata.projectPath, @@ -1065,7 +1097,12 @@ export class AIService extends EventEmitter { ), runtimeTempDir, backgroundProcessManager: this.backgroundProcessManager, + // Plan mode configuration for path enforcement + mode: mode as UIMode | undefined, + planFilePath: mode === "plan" ? getPlanFilePath(workspaceId) : undefined, workspaceId, + // External edit detection callback + recordFileState, }, workspaceId, this.initStateManager, @@ -1231,6 +1268,34 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions ); + // Debug dump: Log the complete LLM request when MUX_DEBUG_LLM_REQUEST is set + // This helps diagnose issues with system prompts, messages, tools, etc. + if (process.env.MUX_DEBUG_LLM_REQUEST === "1") { + const llmRequest = { + workspaceId, + model: modelString, + systemMessage, + messages: finalMessages, + tools: Object.fromEntries( + Object.entries(tools).map(([name, tool]) => [ + name, + { + description: tool.description, + inputSchema: tool.inputSchema, + }, + ]) + ), + providerOptions, + thinkingLevel, + maxOutputTokens, + mode, + toolPolicy, + }; + log.info( + `[MUX_DEBUG_LLM_REQUEST] Full LLM request:\n${JSON.stringify(llmRequest, null, 2)}` + ); + } + // Delegate to StreamManager with model instance, system message, tools, historySequence, and initial metadata const streamResult = await this.streamManager.startStream( workspaceId, diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index e043fa1c40..7a5563d018 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "events"; -import { spawn, spawnSync } from "child_process"; -import * as fs from "fs/promises"; +import { spawn } from "child_process"; import type { Config } from "@/node/config"; import type { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; @@ -13,16 +12,18 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import { log } from "@/node/services/log"; +import { isCommandAvailable, findAvailableCommand } from "@/node/utils/commandDiscovery"; /** * Configuration for opening a native terminal */ type NativeTerminalConfig = - | { type: "local"; workspacePath: string } + | { type: "local"; workspacePath: string; command?: string } | { type: "ssh"; sshConfig: Extract; remotePath: string; + command?: string; }; export class TerminalService { @@ -48,6 +49,13 @@ export class TerminalService { this.terminalWindowManager = manager; } + /** + * Check if we're running in desktop mode (Electron) vs server mode (browser). + */ + isDesktopMode(): boolean { + return !!this.terminalWindowManager; + } + async create(params: TerminalCreateParams): Promise { try { // 1. Resolve workspace @@ -122,6 +130,11 @@ export class TerminalService { this.emitOutput(session.sessionId, data); } + // Send initial command if provided + if (params.initialCommand) { + this.sendInput(session.sessionId, `${params.initialCommand}\n`); + } + return session; } catch (err) { log.error("Error creating terminal session:", err); @@ -236,6 +249,20 @@ export class TerminalService { } } + /** + * Open a native terminal and run a command. + * Used for opening $EDITOR in a terminal when editing files. + * @param command The command to run + * @param workspacePath Optional directory to run the command in (defaults to cwd) + */ + async openNativeWithCommand(command: string, workspacePath?: string): Promise { + await this.openNativeTerminal({ + type: "local", + workspacePath: workspacePath ?? process.cwd(), + command, + }); + } + /** * Open a native terminal (local or SSH) with platform-specific handling. * This spawns the user's native terminal emulator, not a web-based terminal. @@ -282,9 +309,11 @@ export class TerminalService { logPrefix: string ): Promise { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // macOS - try Ghostty first, fallback to Terminal.app - const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); + const terminal = await findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { const cmd = "open"; let args: string[]; @@ -293,10 +322,16 @@ export class TerminalService { // Build the full SSH command as a single string const sshCommand = ["ssh", ...sshArgs].join(" "); args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; + } else if (command) { + // Ghostty: Run command in workspace directory + // Wrap in sh -c to handle cd and command properly + const escapedPath = workspacePath.replace(/'/g, "'\\''"); + const escapedCmd = command.replace(/'/g, "'\\''"); + const fullCommand = `sh -c 'cd "${escapedPath}" && ${escapedCmd}'`; + args = ["-n", "-a", "Ghostty", "--args", `--command=${fullCommand}`]; } else { // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Ghostty", config.workspacePath]; + args = ["-a", "Ghostty", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -306,7 +341,7 @@ export class TerminalService { child.unref(); } else { // Terminal.app - const cmd = isSSH ? "osascript" : "open"; + const cmd = isSSH || command ? "osascript" : "open"; let args: string[]; if (isSSH && sshArgs) { // Terminal.app: Use osascript with proper AppleScript structure @@ -324,10 +359,15 @@ export class TerminalService { const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; args = ["-e", script]; + } else if (command) { + // Terminal.app: Run command in workspace directory via AppleScript + const fullCommand = `cd "${workspacePath}" && ${command}`; + const escapedCommand = fullCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; + args = ["-e", script]; } else { // Terminal.app opens in the directory when passed as argument - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Terminal", config.workspacePath]; + args = ["-a", "Terminal", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -344,6 +384,8 @@ export class TerminalService { logPrefix: string ): void { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // Windows const cmd = "cmd"; @@ -351,9 +393,12 @@ export class TerminalService { if (isSSH && sshArgs) { // Windows - use cmd to start ssh args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs]; + } else if (command) { + // Windows - cd to directory and run command + args = ["/c", "start", "cmd", "/K", `cd /D "${workspacePath}" && ${command}`]; } else { - if (config.type !== "local") throw new Error("Expected local config"); - args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath]; + // Windows - just cd to directory + args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -370,6 +415,8 @@ export class TerminalService { logPrefix: string ): Promise { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // Linux - try terminal emulators in order of preference let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>; @@ -387,9 +434,22 @@ export class TerminalService { { cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] }, { cmd: "xterm", args: ["-e", "ssh", ...sshArgs] }, ]; + } else if (command) { + // Run command in workspace directory + const fullCommand = `cd "${workspacePath}" && ${command}`; + terminals = [ + { cmd: "x-terminal-emulator", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "ghostty", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "alacritty", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "kitty", args: ["sh", "-c", fullCommand] }, + { cmd: "wezterm", args: ["start", "--", "sh", "-c", fullCommand] }, + { cmd: "gnome-terminal", args: ["--", "sh", "-c", fullCommand] }, + { cmd: "konsole", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "xfce4-terminal", args: ["-e", `sh -c '${fullCommand.replace(/'/g, "'\\''")}'`] }, + { cmd: "xterm", args: ["-e", "sh", "-c", fullCommand] }, + ]; } else { - if (config.type !== "local") throw new Error("Expected local config"); - const workspacePath = config.workspacePath; + // Just open terminal in directory terminals = [ { cmd: "x-terminal-emulator", args: [], cwd: workspacePath }, { cmd: "ghostty", args: ["--working-directory=" + workspacePath] }, @@ -422,52 +482,6 @@ export class TerminalService { } } - /** - * Check if a command is available in the system PATH or known locations - */ - private async isCommandAvailable(command: string): Promise { - // Special handling for ghostty on macOS - check common installation paths - if (command === "ghostty" && process.platform === "darwin") { - const ghosttyPaths = [ - "/opt/homebrew/bin/ghostty", - "/Applications/Ghostty.app/Contents/MacOS/ghostty", - "/usr/local/bin/ghostty", - ]; - - for (const ghosttyPath of ghosttyPaths) { - try { - const stats = await fs.stat(ghosttyPath); - // Check if it's a file and any executable bit is set (owner, group, or other) - if (stats.isFile() && (stats.mode & 0o111) !== 0) { - return true; - } - } catch { - // Try next path - } - } - // If none of the known paths work, fall through to which check - } - - try { - const result = spawnSync("which", [command], { encoding: "utf8" }); - return result.status === 0; - } catch { - return false; - } - } - - /** - * Find the first available command from a list of commands - */ - private async findAvailableCommand(commands: string[]): Promise { - for (const cmd of commands) { - if (await this.isCommandAvailable(cmd)) { - return cmd; - } - } - return null; - } - /** * Find the first available terminal emulator from a list */ @@ -475,7 +489,7 @@ export class TerminalService { terminals: Array<{ cmd: string; args: string[]; cwd?: string }> ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { for (const terminal of terminals) { - if (await this.isCommandAvailable(terminal.cmd)) { + if (await isCommandAvailable(terminal.cmd)) { return terminal; } } diff --git a/src/node/services/tools/fileCommon.ts b/src/node/services/tools/fileCommon.ts index 10179b1be9..d407a5e258 100644 --- a/src/node/services/tools/fileCommon.ts +++ b/src/node/services/tools/fileCommon.ts @@ -2,6 +2,7 @@ import * as path from "path"; import { createPatch } from "diff"; import type { FileStat, Runtime } from "@/node/runtime/Runtime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import type { ToolConfiguration } from "@/common/utils/tools/tools"; /** * Maximum file size for file operations (1MB) @@ -28,6 +29,22 @@ export function generateDiff(filePath: string, oldContent: string, newContent: s return createPatch(filePath, oldContent, newContent, "", "", { context: 3 }); } +/** + * Check if a file path is the plan file in plan mode. + * Used to allow access to the plan file even though it's outside the workspace cwd. + * + * @param resolvedPath - The resolved absolute path of the file + * @param config - Tool configuration containing mode and planFilePath + * @returns true if this is the plan file in plan mode + */ +export function isPlanFileAccess(resolvedPath: string, config: ToolConfiguration): boolean { + if (config.mode !== "plan" || !config.planFilePath) { + return false; + } + const resolvedPlanPath = config.runtime.normalizePath(config.planFilePath, config.cwd); + return resolvedPath === resolvedPlanPath; +} + /** * Validates that a file size is within the allowed limit. * Returns an error object if the file is too large, null if valid. diff --git a/src/node/services/tools/file_edit_insert.test.ts b/src/node/services/tools/file_edit_insert.test.ts index aeb64911ff..1f3fccaf18 100644 --- a/src/node/services/tools/file_edit_insert.test.ts +++ b/src/node/services/tools/file_edit_insert.test.ts @@ -123,3 +123,96 @@ describe("file_edit_insert tool", () => { } }); }); + +describe("file_edit_insert plan mode enforcement", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), "plan-mode-insert-")); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("blocks creating non-plan files when in plan mode", async () => { + const planFilePath = path.join(testDir, "sessions", "workspace", "plan.md"); + const otherFilePath = path.join(testDir, "workspace", "main.ts"); + const workspaceCwd = path.join(testDir, "workspace"); + + // Create workspace directory + await fs.mkdir(workspaceCwd, { recursive: true }); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: workspaceCwd, + runtime: createRuntime({ type: "local", srcBaseDir: workspaceCwd }), + runtimeTempDir: testDir, + mode: "plan", + planFilePath: planFilePath, + }); + + const args: FileEditInsertToolArgs = { + file_path: otherFilePath, + content: "console.log('test');", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("In plan mode, only the plan file can be edited"); + } + }); + + it("allows creating plan file when in plan mode", async () => { + const planFilePath = path.join(testDir, "plan.md"); + const workspaceCwd = path.join(testDir, "workspace"); + + // Create workspace directory + await fs.mkdir(workspaceCwd, { recursive: true }); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: workspaceCwd, + runtime: createRuntime({ type: "local", srcBaseDir: workspaceCwd }), + runtimeTempDir: testDir, + mode: "plan", + planFilePath: planFilePath, + }); + + const args: FileEditInsertToolArgs = { + file_path: planFilePath, + content: "# My Plan\n\n- Step 1\n- Step 2\n", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(true); + expect(await fs.readFile(planFilePath, "utf-8")).toBe("# My Plan\n\n- Step 1\n- Step 2\n"); + }); + + it("allows editing any file in exec mode", async () => { + const testFilePath = path.join(testDir, "main.ts"); + await fs.writeFile(testFilePath, "const x = 1;"); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: testDir, + runtime: createRuntime({ type: "local", srcBaseDir: testDir }), + runtimeTempDir: testDir, + mode: "exec", + }); + + const args: FileEditInsertToolArgs = { + file_path: testFilePath, + content: "// header\n", + after: "const x = 1;", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(true); + expect(await fs.readFile(testFilePath, "utf-8")).toBe("// header\nconst x = 1;"); + }); +}); diff --git a/src/node/services/tools/file_edit_insert.ts b/src/node/services/tools/file_edit_insert.ts index 267b6d00cf..8dea2f84d0 100644 --- a/src/node/services/tools/file_edit_insert.ts +++ b/src/node/services/tools/file_edit_insert.ts @@ -3,7 +3,12 @@ import type { FileEditInsertToolArgs, FileEditInsertToolResult } from "@/common/ import { EDIT_FAILED_NOTE_PREFIX, NOTE_READ_FILE_RETRY } from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; -import { generateDiff, validateAndCorrectPath, validatePathInCwd } from "./fileCommon"; +import { + generateDiff, + validateAndCorrectPath, + validatePathInCwd, + isPlanFileAccess, +} from "./fileCommon"; import { executeFileEditOperation } from "./file_edit_operation"; import { fileExists } from "@/node/utils/runtime/fileExists"; import { writeFileString } from "@/node/utils/runtime/helpers"; @@ -59,20 +64,46 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) ); file_path = correctedPath; - const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; + const resolvedPath = config.runtime.normalizePath(file_path, config.cwd); + + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(file_path, config.cwd) + : resolvedPath; + + // Plan mode restriction: only allow editing/creating the plan file + if (config.mode === "plan" && config.planFilePath) { + if (!isPlanFile) { + return { + success: false, + error: `In plan mode, only the plan file can be edited. Attempted to edit: ${file_path}`, + }; + } + // Skip cwd validation for plan file - it's intentionally outside workspace + } else { + // Standard cwd validation for non-plan-mode edits + const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } } - const resolvedPath = config.runtime.normalizePath(file_path, config.cwd); - const exists = await fileExists(config.runtime, resolvedPath, abortSignal); + const exists = await fileExists(effectiveRuntime, effectiveResolvedPath, abortSignal); if (!exists) { try { - await writeFileString(config.runtime, resolvedPath, content, abortSignal); + await writeFileString(effectiveRuntime, effectiveResolvedPath, content, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -83,7 +114,7 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) throw err; } - const diff = generateDiff(resolvedPath, "", content); + const diff = generateDiff(effectiveResolvedPath, "", content); return { success: true, diff, diff --git a/src/node/services/tools/file_edit_operation.test.ts b/src/node/services/tools/file_edit_operation.test.ts index de13e93a21..fe480f2e75 100644 --- a/src/node/services/tools/file_edit_operation.test.ts +++ b/src/node/services/tools/file_edit_operation.test.ts @@ -1,8 +1,11 @@ import { describe, test, expect, jest } from "@jest/globals"; +import * as fs from "fs/promises"; +import * as path from "path"; import { executeFileEditOperation } from "./file_edit_operation"; import type { Runtime } from "@/node/runtime/Runtime"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import { createTestToolConfig, getTestDeps } from "./testHelpers"; +import { createTestToolConfig, getTestDeps, TestTempDir } from "./testHelpers"; const TEST_CWD = "/tmp"; @@ -85,3 +88,169 @@ describe("executeFileEditOperation", () => { } }); }); + +describe("executeFileEditOperation plan mode enforcement", () => { + test("should block editing non-plan files when in plan mode", async () => { + // This test verifies that when in plan mode with a planFilePath set, + // attempting to edit any other file is blocked BEFORE trying to read/write + const OTHER_FILE_PATH = "/home/user/project/src/main.ts"; + const PLAN_FILE_PATH = "/home/user/.mux/sessions/workspace-123/plan.md"; + const TEST_CWD = "/home/user/project"; + + const readFileMock = jest.fn(); + const mockRuntime = { + stat: jest + .fn<() => Promise<{ size: number; modifiedTime: Date; isDirectory: boolean }>>() + .mockResolvedValue({ + size: 100, + modifiedTime: new Date(), + isDirectory: false, + }), + readFile: readFileMock, + writeFile: jest.fn(), + normalizePath: jest.fn<(targetPath: string, _basePath: string) => string>( + (targetPath: string, _basePath: string) => { + // For absolute paths, return as-is + if (targetPath.startsWith("/")) return targetPath; + // For relative paths, join with base + return `${_basePath}/${targetPath}`; + } + ), + } as unknown as Runtime; + + const result = await executeFileEditOperation({ + config: { + cwd: TEST_CWD, + runtime: mockRuntime, + runtimeTempDir: "/tmp", + mode: "plan", + planFilePath: PLAN_FILE_PATH, + }, + filePath: OTHER_FILE_PATH, + operation: () => ({ success: true, newContent: "console.log('test')", metadata: {} }), + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("In plan mode, only the plan file can be edited"); + expect(result.error).toContain(OTHER_FILE_PATH); + } + + // Verify readFile was never called - we should fail before reaching file IO + expect(readFileMock).not.toHaveBeenCalled(); + }); + + test("should allow editing the plan file when in plan mode (integration)", async () => { + using tempDir = new TestTempDir("plan-mode-test"); + + // Create the plan file in the temp directory + const planPath = path.join(tempDir.path, "plan.md"); + await fs.writeFile(planPath, "# Original Plan\n"); + + // CWD is separate from plan file location (simulates real setup) + const workspaceCwd = path.join(tempDir.path, "workspace"); + await fs.mkdir(workspaceCwd); + + const result = await executeFileEditOperation({ + config: { + cwd: workspaceCwd, + runtime: new LocalRuntime(workspaceCwd, tempDir.path), + runtimeTempDir: tempDir.path, + mode: "plan", + planFilePath: planPath, + }, + filePath: planPath, + operation: () => ({ success: true, newContent: "# Updated Plan\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(planPath, "utf-8")).toBe("# Updated Plan\n"); + }); + + test("should allow editing any file when in exec mode (integration)", async () => { + using tempDir = new TestTempDir("exec-mode-test"); + + const testFile = path.join(tempDir.path, "main.ts"); + await fs.writeFile(testFile, "const x = 1;\n"); + + const result = await executeFileEditOperation({ + config: { + cwd: tempDir.path, + runtime: new LocalRuntime(tempDir.path, tempDir.path), + runtimeTempDir: tempDir.path, + mode: "exec", + // No planFilePath in exec mode + }, + filePath: testFile, + operation: () => ({ success: true, newContent: "const x = 2;\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(testFile, "utf-8")).toBe("const x = 2;\n"); + }); + + test("should allow editing any file when mode is not set (integration)", async () => { + using tempDir = new TestTempDir("no-mode-test"); + + const testFile = path.join(tempDir.path, "main.ts"); + await fs.writeFile(testFile, "const x = 1;\n"); + + const result = await executeFileEditOperation({ + config: { + cwd: tempDir.path, + runtime: new LocalRuntime(tempDir.path, tempDir.path), + runtimeTempDir: tempDir.path, + // mode is undefined + }, + filePath: testFile, + operation: () => ({ success: true, newContent: "const x = 2;\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(testFile, "utf-8")).toBe("const x = 2;\n"); + }); + + test("should handle relative path to plan file in plan mode", async () => { + // When user provides a relative path that resolves to the plan file, + // it should still be allowed + const normalizePathCalls: string[] = []; + + const mockRuntime = { + stat: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), + normalizePath: jest.fn<(targetPath: string, basePath: string) => string>( + (targetPath: string, basePath: string) => { + normalizePathCalls.push(targetPath); + // Simulate: "../.mux/sessions/ws/plan.md" resolves to "/home/user/.mux/sessions/ws/plan.md" + if (targetPath === "../.mux/sessions/ws/plan.md") { + return "/home/user/.mux/sessions/ws/plan.md"; + } + if (targetPath === "/home/user/.mux/sessions/ws/plan.md") { + return "/home/user/.mux/sessions/ws/plan.md"; + } + if (targetPath.startsWith("/")) return targetPath; + return `${basePath}/${targetPath}`; + } + ), + } as unknown as Runtime; + + await executeFileEditOperation({ + config: { + cwd: "/home/user/project", + runtime: mockRuntime, + runtimeTempDir: "/tmp", + mode: "plan", + planFilePath: "/home/user/.mux/sessions/ws/plan.md", + }, + filePath: "../.mux/sessions/ws/plan.md", // Relative path to plan file + operation: () => ({ success: true, newContent: "# Plan", metadata: {} }), + }); + + // This will fail at file read (because mock doesn't provide real stream), + // but the key is that it passes the plan mode check and tries to read the file + // So we check that normalizePath was called for both paths + expect(normalizePathCalls).toContain("../.mux/sessions/ws/plan.md"); + expect(normalizePathCalls).toContain("/home/user/.mux/sessions/ws/plan.md"); + }); +}); diff --git a/src/node/services/tools/file_edit_operation.ts b/src/node/services/tools/file_edit_operation.ts index ccf5bf20d9..7cd6113b1f 100644 --- a/src/node/services/tools/file_edit_operation.ts +++ b/src/node/services/tools/file_edit_operation.ts @@ -5,6 +5,7 @@ import { validateFileSize, validatePathInCwd, validateAndCorrectPath, + isPlanFileAccess, } from "./fileCommon"; import { RuntimeError } from "@/node/runtime/Runtime"; import { readFileString, writeFileString } from "@/node/utils/runtime/helpers"; @@ -51,22 +52,47 @@ export async function executeFileEditOperation({ ); filePath = validatedPath; - const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; - } - // Use runtime's normalizePath method to resolve paths correctly for both local and SSH runtimes // This ensures path resolution uses runtime-specific semantics instead of Node.js path module const resolvedPath = config.runtime.normalizePath(filePath, config.cwd); + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(filePath, config.cwd) + : resolvedPath; + + // Plan mode restriction: only allow editing the plan file + if (config.mode === "plan" && config.planFilePath) { + if (!isPlanFile) { + return { + success: false, + error: `In plan mode, only the plan file can be edited. Attempted to edit: ${filePath}`, + }; + } + // Skip cwd validation for plan file - it's intentionally outside workspace + } else { + // Standard cwd validation for non-plan-mode edits + const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } + } + // Check if file exists and get stats using runtime let fileStat; try { - fileStat = await config.runtime.stat(resolvedPath, abortSignal); + fileStat = await effectiveRuntime.stat(effectiveResolvedPath, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -80,7 +106,7 @@ export async function executeFileEditOperation({ if (fileStat.isDirectory) { return { success: false, - error: `Path is a directory, not a file: ${resolvedPath}`, + error: `Path is a directory, not a file: ${effectiveResolvedPath}`, }; } @@ -95,7 +121,7 @@ export async function executeFileEditOperation({ // Read file content using runtime helper let originalContent: string; try { - originalContent = await readFileString(config.runtime, resolvedPath, abortSignal); + originalContent = await readFileString(effectiveRuntime, effectiveResolvedPath, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -117,7 +143,12 @@ export async function executeFileEditOperation({ // Write file using runtime helper try { - await writeFileString(config.runtime, resolvedPath, operationResult.newContent, abortSignal); + await writeFileString( + effectiveRuntime, + effectiveResolvedPath, + operationResult.newContent, + abortSignal + ); } catch (err) { if (err instanceof RuntimeError) { return { @@ -128,7 +159,7 @@ export async function executeFileEditOperation({ throw err; } - const diff = generateDiff(resolvedPath, originalContent, operationResult.newContent); + const diff = generateDiff(effectiveResolvedPath, originalContent, operationResult.newContent); return { success: true, diff --git a/src/node/services/tools/file_read.ts b/src/node/services/tools/file_read.ts index 1075ed92be..bee17f7b61 100644 --- a/src/node/services/tools/file_read.ts +++ b/src/node/services/tools/file_read.ts @@ -2,7 +2,12 @@ import { tool } from "ai"; import type { FileReadToolResult } from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; -import { validatePathInCwd, validateFileSize, validateAndCorrectPath } from "./fileCommon"; +import { + validatePathInCwd, + validateFileSize, + validateAndCorrectPath, + isPlanFileAccess, +} from "./fileCommon"; import { RuntimeError } from "@/node/runtime/Runtime"; import { readFileString } from "@/node/utils/runtime/helpers"; @@ -30,22 +35,38 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { ); filePath = validatedPath; - // Validate that the path is within the working directory - const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; - } - // Use runtime's normalizePath method to resolve paths correctly for both local and SSH runtimes const resolvedPath = config.runtime.normalizePath(filePath, config.cwd); + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(filePath, config.cwd) + : resolvedPath; + + // Validate that the path is within the working directory + // Exception: allow reading the plan file in plan mode (it's outside workspace cwd) + if (!isPlanFile) { + const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } + } + // Check if file exists using runtime let fileStat; try { - fileStat = await config.runtime.stat(resolvedPath); + fileStat = await effectiveRuntime.stat(effectiveResolvedPath); } catch (err) { if (err instanceof RuntimeError) { return { @@ -59,7 +80,7 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { if (fileStat.isDirectory) { return { success: false, - error: `Path is a directory, not a file: ${resolvedPath}`, + error: `Path is a directory, not a file: ${effectiveResolvedPath}`, }; } @@ -75,7 +96,7 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { // Read full file content using runtime helper let fullContent: string; try { - fullContent = await readFileString(config.runtime, resolvedPath); + fullContent = await readFileString(effectiveRuntime, effectiveResolvedPath); } catch (err) { if (err instanceof RuntimeError) { return { diff --git a/src/node/services/tools/propose_plan.ts b/src/node/services/tools/propose_plan.ts index 7ea89bd94f..bca9d66d31 100644 --- a/src/node/services/tools/propose_plan.ts +++ b/src/node/services/tools/propose_plan.ts @@ -1,25 +1,67 @@ +import { stat } from "fs/promises"; import { tool } from "ai"; +import { z } from "zod"; import type { ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { getPlanFilePath, readPlanFile } from "@/common/utils/planStorage"; + +// Schema for propose_plan - empty object (no input parameters) +// Defined locally to avoid type inference issues with `as const` in TOOL_DEFINITIONS +const proposePlanSchema = z.object({}); /** - * Propose plan tool factory for AI assistant - * Creates a tool that allows the AI to propose a plan for approval before execution - * @param config Required configuration (not used for this tool, but required by interface) + * Propose plan tool factory for AI assistant. + * The tool reads the plan from the plan file the agent wrote to. + * If the plan file doesn't exist, it returns an error instructing + * the agent to write the plan first. */ -export const createProposePlanTool: ToolFactory = () => { +export const createProposePlanTool: ToolFactory = (config) => { return tool({ description: TOOL_DEFINITIONS.propose_plan.description, - inputSchema: TOOL_DEFINITIONS.propose_plan.schema, - execute: ({ title, plan }) => { - // Tool execution is a no-op on the backend - // The plan is displayed in the frontend and user decides whether to approve - return Promise.resolve({ - success: true, - title, - plan, + inputSchema: proposePlanSchema, + execute: async () => { + const workspaceId = config.workspaceId; + + if (!workspaceId) { + return { + success: false as const, + error: "No workspace ID available. Cannot determine plan file location.", + }; + } + + const planPath = getPlanFilePath(workspaceId); + const planContent = readPlanFile(workspaceId); + + if (planContent === null) { + return { + success: false as const, + error: `No plan file found at ${planPath}. Please write your plan to this file before calling propose_plan.`, + }; + } + + if (planContent === "") { + return { + success: false as const, + error: `Plan file at ${planPath} is empty. Please write your plan content before calling propose_plan.`, + }; + } + + // Record file state for external edit detection + if (config.recordFileState) { + try { + const mtime = (await stat(planPath)).mtimeMs; + config.recordFileState(planPath, { content: planContent, timestamp: mtime }); + } catch { + // File stat failed, skip recording (shouldn't happen since we just read it) + } + } + + return { + success: true as const, + planPath, + planContent, message: "Plan proposed. Waiting for user approval.", - }); + }; }, }); }; diff --git a/src/node/utils/commandDiscovery.ts b/src/node/utils/commandDiscovery.ts new file mode 100644 index 0000000000..4d1ff3788c --- /dev/null +++ b/src/node/utils/commandDiscovery.ts @@ -0,0 +1,74 @@ +/** + * Utilities for discovering available commands in the system. + * Used by terminalService and openInEditor to find executables. + */ + +import * as fs from "fs/promises"; +import { spawnSync } from "child_process"; + +/** Known installation paths for GUI editors on macOS */ +const MACOS_APP_PATHS: Record = { + cursor: ["/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "/usr/local/bin/cursor"], + code: [ + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + "/usr/local/bin/code", + ], + zed: ["/Applications/Zed.app/Contents/MacOS/cli", "/usr/local/bin/zed"], + subl: ["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", "/usr/local/bin/subl"], + ghostty: [ + "/opt/homebrew/bin/ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/usr/local/bin/ghostty", + ], +}; + +/** + * Check if a command is available in the system PATH or known locations. + * First checks macOS-specific paths for known apps, then falls back to `which`. + */ +export async function isCommandAvailable(command: string): Promise { + // Check known paths for macOS apps + if (process.platform === "darwin" && command in MACOS_APP_PATHS) { + for (const appPath of MACOS_APP_PATHS[command]) { + try { + const stats = await fs.stat(appPath); + // Check if it's a file and any executable bit is set + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + return true; + } + } catch { + // Try next path + } + } + } + + // Fall back to which + try { + const result = spawnSync("which", [command], { encoding: "utf8" }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Find the first available command from a list of candidates. + * Returns the command name (not full path) of the first available one. + */ +export async function findAvailableCommand(commands: string[]): Promise { + for (const cmd of commands) { + if (await isCommandAvailable(cmd)) { + return cmd; + } + } + return null; +} + +/** GUI editors that spawn detached (no terminal needed) */ +export const GUI_EDITORS = ["cursor", "code", "zed", "subl"] as const; + +/** Terminal editors that require a terminal session */ +export const TERMINAL_EDITORS = ["nvim", "vim", "vi", "nano", "emacs"] as const; + +/** All known GUI terminal emulators */ +export const TERMINAL_EMULATORS = ["ghostty", "kitty", "alacritty", "wezterm", "iterm2"] as const; diff --git a/src/node/utils/diff.ts b/src/node/utils/diff.ts new file mode 100644 index 0000000000..22c4f29dfc --- /dev/null +++ b/src/node/utils/diff.ts @@ -0,0 +1,235 @@ +/** + * Simple line-based diff utility for detecting external file edits. + * Uses timestamp-based polling with diff injection. + */ + +/** + * Compute a unified diff between old and new content. + * Returns null if contents are identical. + * + * The diff format shows: + * - Lines prefixed with '-' were removed + * - Lines prefixed with '+' were added + * - Context lines (unchanged) are shown around changes + */ +export function computeDiff(oldContent: string, newContent: string): string | null { + if (oldContent === newContent) return null; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + + // Simple line-by-line comparison with context + const changes: string[] = []; + const contextSize = 3; + + // Find all changed line ranges using longest common subsequence approach + const lcs = computeLCS(oldLines, newLines); + const hunks = buildHunks(oldLines, newLines, lcs, contextSize); + + if (hunks.length === 0) { + // Edge case: whitespace-only differences that don't show up in line comparison + return null; + } + + for (const hunk of hunks) { + changes.push( + `@@ -${hunk.oldStart + 1},${hunk.oldCount} +${hunk.newStart + 1},${hunk.newCount} @@` + ); + changes.push(...hunk.lines); + } + + return changes.join("\n"); +} + +interface Hunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: string[]; +} + +type LCSMatch = [number, number]; + +/** + * Compute longest common subsequence indices. + * Returns array of [oldIndex, newIndex] pairs for matching lines. + */ +function computeLCS(oldLines: string[], newLines: string[]): LCSMatch[] { + const m = oldLines.length; + const n = newLines.length; + + // Build LCS table + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0) + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find matching pairs + const result: LCSMatch[] = []; + let i = m; + let j = n; + + while (i > 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + result.unshift([i - 1, j - 1]); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return result; +} + +/** + * Build unified diff hunks from LCS matches. + */ +function buildHunks( + oldLines: string[], + newLines: string[], + lcs: LCSMatch[], + contextSize: number +): Hunk[] { + const hunks: Hunk[] = []; + let currentHunk: Hunk | null = null; + + let oldIdx = 0; + let newIdx = 0; + let lcsIdx = 0; + + while (oldIdx < oldLines.length || newIdx < newLines.length) { + const match = lcsIdx < lcs.length ? lcs[lcsIdx] : null; + + if (match?.[0] === oldIdx && match[1] === newIdx) { + // Matching line + if (currentHunk) { + currentHunk.lines.push(` ${oldLines[oldIdx]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + } + oldIdx++; + newIdx++; + lcsIdx++; + } else { + // Non-matching: we have deletions or additions + const startHunk = !currentHunk; + if (startHunk) { + // Start new hunk with context + currentHunk = { + oldStart: Math.max(0, oldIdx - contextSize), + oldCount: 0, + newStart: Math.max(0, newIdx - contextSize), + newCount: 0, + lines: [], + }; + + // Add leading context + const contextStart = Math.max(0, oldIdx - contextSize); + for (let c = contextStart; c < oldIdx; c++) { + currentHunk.lines.push(` ${oldLines[c]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + } + } + + // Add deletions (old lines not in new) + while (oldIdx < oldLines.length && (!match || oldIdx < match[0])) { + currentHunk!.lines.push(`-${oldLines[oldIdx]}`); + currentHunk!.oldCount++; + oldIdx++; + } + + // Add additions (new lines not in old) + while (newIdx < newLines.length && (!match || newIdx < match[1])) { + currentHunk!.lines.push(`+${newLines[newIdx]}`); + currentHunk!.newCount++; + newIdx++; + } + } + + // Check if we should close the hunk (enough context after changes) + if (currentHunk && match?.[0] === oldIdx && match[1] === newIdx) { + // Look ahead to see if there are more changes within context range + const nextNonMatch = findNextNonMatch(oldIdx, newIdx, lcs, lcsIdx, oldLines, newLines); + + if (nextNonMatch === null || nextNonMatch > contextSize * 2) { + // No more changes nearby, add trailing context and close hunk + let contextAdded = 0; + while ( + contextAdded < contextSize && + oldIdx < oldLines.length && + newIdx < newLines.length && + lcsIdx < lcs.length && + lcs[lcsIdx][0] === oldIdx && + lcs[lcsIdx][1] === newIdx + ) { + currentHunk.lines.push(` ${oldLines[oldIdx]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + oldIdx++; + newIdx++; + lcsIdx++; + contextAdded++; + } + + hunks.push(currentHunk); + currentHunk = null; + } + } + } + + // Close any remaining hunk + if (currentHunk) { + hunks.push(currentHunk); + } + + return hunks; +} + +/** + * Find distance to next non-matching pair. + */ +function findNextNonMatch( + oldIdx: number, + newIdx: number, + lcs: LCSMatch[], + lcsIdx: number, + oldLines: string[], + newLines: string[] +): number | null { + let distance = 0; + let oi = oldIdx; + let ni = newIdx; + let li = lcsIdx; + + while (oi < oldLines.length && ni < newLines.length && li < lcs.length) { + if (lcs[li][0] === oi && lcs[li][1] === ni) { + oi++; + ni++; + li++; + distance++; + } else { + return distance; + } + } + + // Reached end of one or both arrays + if (oi < oldLines.length || ni < newLines.length) { + return distance; + } + + return null; +} diff --git a/tests/ipc/fileChangeNotification.test.ts b/tests/ipc/fileChangeNotification.test.ts new file mode 100644 index 0000000000..9774511983 --- /dev/null +++ b/tests/ipc/fileChangeNotification.test.ts @@ -0,0 +1,284 @@ +/** + * Integration test for file change notification injection. + * + * Tests that when a tracked file (like a plan file) is modified externally, + * the change is detected and injected as a synthetic user message with + * tags into the message stream sent to the LLM. + * + * This tests the cache-preserving approach where file changes are communicated + * via user messages rather than system message modifications. + */ +import { writeFile, mkdir, stat, utimes, readFile, readdir } from "fs/promises"; +import { join } from "path"; +import { + createTestEnvironment, + cleanupTestEnvironment, + setupProviders, + preloadTestModules, + type TestEnvironment, + shouldRunIntegrationTests, +} from "./setup"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createStreamCollector, + HAIKU_MODEL, + STREAM_TIMEOUT_LOCAL_MS, +} from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/node/git"; +import { getApiKey, validateApiKeys } from "../testUtils"; +import { getPlanFilePath } from "../../src/common/utils/planStorage"; +import { log } from "../../src/node/services/log"; +import { getMuxHome } from "../../src/common/constants/paths"; + +// Skip tests if integration tests are disabled or API keys are missing +const runTests = shouldRunIntegrationTests(); + +// Validate API keys are available +if (runTests) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +const describeIntegration = runTests ? describe : describe.skip; + +describeIntegration("File Change Notification Integration", () => { + let env: TestEnvironment; + let repoPath: string; + let originalLogLevel: ReturnType; + + beforeAll(async () => { + await preloadTestModules(); + env = await createTestEnvironment(); + repoPath = await createTempGitRepo(); + + // Setup Anthropic provider + const apiKey = getApiKey("ANTHROPIC_API_KEY"); + await setupProviders(env, { + anthropic: { apiKey }, + }); + }, 30000); + + afterAll(async () => { + if (repoPath) { + await cleanupTempGitRepo(repoPath); + } + if (env) { + await cleanupTestEnvironment(env); + } + }); + + beforeEach(() => { + // Enable debug mode to ensure debug_obj files are written + originalLogLevel = log.getLevel(); + log.setLevel("debug"); + }); + + afterEach(() => { + // Restore original log level + log.setLevel(originalLogLevel); + }); + + it("should inject file change notification when tracked plan file is modified externally", async () => { + // 1. Create a workspace + const branchName = generateBranchName("file-change-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // 2. Get the AgentSession and plan file path + const session = env.services.workspaceService.getOrCreateSession(workspaceId); + const planPath = getPlanFilePath(workspaceId); + + // 3. Create the plan directory and file + const planDir = join(planPath, ".."); + await mkdir(planDir, { recursive: true }); + + const originalContent = "# Plan\n\n## Step 1\n\nOriginal plan content"; + await writeFile(planPath, originalContent); + + // 4. Record the file state (simulates what propose_plan does) + const { mtimeMs: originalMtime } = await stat(planPath); + session.recordFileState(planPath, { + content: originalContent, + timestamp: originalMtime, + }); + + // 5. Modify the file externally (simulate user edit) + await new Promise((resolve) => setTimeout(resolve, 50)); // Ensure mtime changes + const modifiedContent = + "# Plan\n\n## Step 1\n\nModified plan content\n\n## Step 2\n\nNew step added"; + await writeFile(planPath, modifiedContent); + + // Update mtime to be clearly in the future + const newMtime = Date.now() + 1000; + await utimes(planPath, newMtime / 1000, newMtime / 1000); + + // 6. Set up stream collector and send a message + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Send a simple message to trigger LLM call + const sendResult = await env.orpc.workspace.sendMessage({ + workspaceId, + message: "Continue with the plan", + options: { + model: HAIKU_MODEL, + mode: "exec", + thinkingLevel: "off", + }, + }); + + expect(sendResult.success).toBe(true); + + // Wait for stream to complete + await collector.waitForEvent("stream-end", STREAM_TIMEOUT_LOCAL_MS); + collector.stop(); + + // 7. Check the debug log file for the injected message + // The messages with file changes are logged to ~/.mux/debug_obj/${workspaceId}/2a_redacted_messages.json + const debugObjDir = join(getMuxHome(), "debug_obj", workspaceId); + const debugFiles = await readdir(debugObjDir).catch(() => [] as string[]); + + // Find the redacted messages file + const redactedFile = debugFiles.find((f) => f.includes("2a_redacted_messages")); + expect(redactedFile).toBeDefined(); + + if (redactedFile) { + const redactedPath = join(debugObjDir, redactedFile); + const content = await readFile(redactedPath, "utf-8"); + const messages = JSON.parse(content) as Array<{ + role: string; + parts?: Array<{ type: string; text?: string }>; + metadata?: { synthetic?: boolean }; + }>; + + // Find the synthetic file change message + const fileChangeMessage = messages.find( + (m) => + m.role === "user" && + m.metadata?.synthetic === true && + m.parts?.some((p) => p.type === "text" && p.text?.includes("")) + ); + + expect(fileChangeMessage).toBeDefined(); + + if (fileChangeMessage) { + const textPart = fileChangeMessage.parts?.find((p) => p.type === "text"); + expect(textPart?.text).toContain(""); + expect(textPart?.text).toContain(""); + expect(textPart?.text).toContain("was modified"); + // Should contain the diff showing the changes + expect(textPart?.text).toContain("Modified plan content"); + expect(textPart?.text).toContain("Step 2"); + expect(textPart?.text).toContain("New step added"); + } + } + } finally { + // Cleanup workspace + await env.orpc.workspace.remove({ workspaceId }); + } + }, 60000); + + it("should not inject notification when tracked file is unchanged", async () => { + // 1. Create a workspace + const branchName = generateBranchName("file-unchanged-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // 2. Get the AgentSession and plan file path + const session = env.services.workspaceService.getOrCreateSession(workspaceId); + const planPath = getPlanFilePath(workspaceId); + + // 3. Create the plan directory and file + const planDir = join(planPath, ".."); + await mkdir(planDir, { recursive: true }); + + const originalContent = "# Plan\n\nUnchanged content"; + await writeFile(planPath, originalContent); + + // 4. Record the file state + const { mtimeMs: originalMtime } = await stat(planPath); + session.recordFileState(planPath, { + content: originalContent, + timestamp: originalMtime, + }); + + // 5. DO NOT modify the file - leave it unchanged + + // 6. Set up stream collector and send a message + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Send a simple message to trigger LLM call + const sendResult = await env.orpc.workspace.sendMessage({ + workspaceId, + message: "Hello", + options: { + model: HAIKU_MODEL, + mode: "exec", + thinkingLevel: "off", + }, + }); + + expect(sendResult.success).toBe(true); + + // Wait for stream to complete + await collector.waitForEvent("stream-end", STREAM_TIMEOUT_LOCAL_MS); + collector.stop(); + + // 7. Check the debug log file - should NOT have file change notification + const debugObjDir = join(getMuxHome(), "debug_obj", workspaceId); + const debugFiles = await readdir(debugObjDir).catch(() => [] as string[]); + + const redactedFile = debugFiles.find((f) => f.includes("2a_redacted_messages")); + expect(redactedFile).toBeDefined(); + + if (redactedFile) { + const redactedPath = join(debugObjDir, redactedFile); + const content = await readFile(redactedPath, "utf-8"); + const messages = JSON.parse(content) as Array<{ + role: string; + parts?: Array<{ type: string; text?: string }>; + metadata?: { synthetic?: boolean }; + }>; + + // Should NOT find a file change message + const fileChangeMessage = messages.find( + (m) => + m.role === "user" && + m.metadata?.synthetic === true && + m.parts?.some((p) => p.type === "text" && p.text?.includes("")) + ); + + expect(fileChangeMessage).toBeUndefined(); + } + } finally { + // Cleanup workspace + await env.orpc.workspace.remove({ workspaceId }); + } + }, 60000); +}); diff --git a/tests/ipc/planCommands.test.ts b/tests/ipc/planCommands.test.ts new file mode 100644 index 0000000000..e620a33033 --- /dev/null +++ b/tests/ipc/planCommands.test.ts @@ -0,0 +1,277 @@ +/** + * Integration tests for plan commands (/plan, /plan open) + * + * Tests: + * - getPlanContent API returns plan file content + * - openInEditor API attempts to open file with configured editor + * - Plan file CRUD operations + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import type { TestEnvironment } from "./setup"; +import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/node/git"; +import { getPlanFilePath } from "../../src/common/utils/planStorage"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Plan Commands Integration", () => { + let env: TestEnvironment; + let repoPath: string; + + beforeAll(async () => { + env = await createTestEnvironment(); + repoPath = await createTempGitRepo(); + }, 30000); + + afterAll(async () => { + if (repoPath) { + await cleanupTempGitRepo(repoPath); + } + if (env) { + await cleanupTestEnvironment(env); + } + }); + + describe("getPlanContent", () => { + it("should return error when no plan file exists", async () => { + const branchName = generateBranchName("plan-no-file"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("not found"); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + + it("should return plan content when plan file exists", async () => { + const branchName = generateBranchName("plan-with-file"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create a plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + + const planContent = "# Test Plan\n\n## Step 1\n\nDo something\n\n## Step 2\n\nDo more"; + await fs.writeFile(planPath, planContent); + + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toBe(planContent); + expect(result.data.path).toBe(planPath); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + + it("should handle empty plan file", async () => { + const branchName = generateBranchName("plan-empty"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create an empty plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + await fs.writeFile(planPath, ""); + + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + // Empty file should still be returned (not an error) + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toBe(""); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + }); + + describe("canOpenInEditor", () => { + it("should return method based on available editors", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Clear VISUAL and EDITOR to test discovery + delete process.env.VISUAL; + delete process.env.EDITOR; + + const result = await env.orpc.general.canOpenInEditor(); + + // In CI without editors, method depends on what's discoverable + expect(result).toHaveProperty("method"); + expect(["visual", "editor", "gui-fallback", "terminal-fallback", "none"]).toContain( + result.method + ); + + // If an editor was found, editor field should be set + if (result.method !== "none") { + expect(result.editor).toBeDefined(); + } + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } + } + }, 30000); + + it("should return method=editor when EDITOR is set", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Set EDITOR, clear VISUAL (VISUAL takes priority) + delete process.env.VISUAL; + process.env.EDITOR = "vim"; + + const result = await env.orpc.general.canOpenInEditor(); + + expect(result.method).toBe("editor"); + expect(result.editor).toBe("vim"); + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } else { + delete process.env.VISUAL; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } else { + delete process.env.EDITOR; + } + } + }, 30000); + + it("should return method=visual when VISUAL is set", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Set VISUAL (takes priority over EDITOR) + process.env.VISUAL = "code"; + process.env.EDITOR = "vim"; + + const result = await env.orpc.general.canOpenInEditor(); + + expect(result.method).toBe("visual"); + expect(result.editor).toBe("code"); + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } else { + delete process.env.VISUAL; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } else { + delete process.env.EDITOR; + } + } + }, 30000); + }); + + describe("openInEditor", () => { + it("should return result without throwing", async () => { + const branchName = generateBranchName("plan-open-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create a plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + await fs.writeFile(planPath, "# Test Plan"); + + // Check if any editor is available first + const canEdit = await env.orpc.general.canOpenInEditor(); + + const result = await env.orpc.general.openInEditor({ + filePath: planPath, + workspaceId, + }); + + // Should return a result (success or failure) without throwing + expect(result).toBeDefined(); + + // If no editor available, should return error + if (canEdit.method === "none") { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("No editor available"); + } + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + }); +});