Skip to content

Commit dc6e5b1

Browse files
authored
🤖 feat: refactor Plan Mode with external file change detection (#977)
## Summary - **Plan Mode workflow**: Toggle between Plan/Exec modes (Cmd+Shift+M). In Plan mode, file edits restricted to `~/.mux/plans/<workspace-id>.md`. Agent proposes plans via `propose_plan` tool for user review before implementation. - **External file change detection**: Timestamp-based polling detects external edits to plan files, computes unified diffs, injects as synthetic user messages to keep agent aware without breaking prompt cache. - **ProposePlan UI**: Markdown rendering, edit-in-editor button, "Start Here" context reset, fresh content on window focus. - **File edit refactoring**: Extracted shared execution pipeline, runtime-aware path validation, custom diff utility replacing external dependency. ## Test plan - [ ] Toggle Plan/Exec mode with keyboard shortcut - [ ] Verify file edits blocked for non-plan files in Plan mode - [ ] Edit plan externally, confirm agent sees changes on next query - [ ] Run make test for unit test coverage - [ ] Run TEST_INTEGRATION=1 bun x jest tests/ipc/fileChangeNotification.test.ts for integration tests _Generated with `mux`_
1 parent 01bd91d commit dc6e5b1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+3314
-280
lines changed

docs/cli.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,25 @@ Print the version and git commit:
102102
mux --version
103103
# v0.8.4 (abc123)
104104
```
105+
106+
## Debug Environment Variables
107+
108+
These environment variables help diagnose issues with LLM requests and responses.
109+
110+
| Variable | Purpose |
111+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112+
| `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. |
113+
114+
Example usage:
115+
116+
```bash
117+
MUX_DEBUG_LLM_REQUEST=1 mux run "Hello world"
118+
```
119+
120+
The output includes:
121+
122+
- `systemMessage`: The full system prompt sent to the model
123+
- `messages`: All conversation messages in the request
124+
- `tools`: Tool definitions with descriptions and input schemas
125+
- `providerOptions`: Provider-specific options (thinking level, etc.)
126+
- `mode`, `thinkingLevel`, `maxOutputTokens`, `toolPolicy`

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"init-hooks"
4747
]
4848
},
49+
"plan-mode",
4950
"vscode-extension",
5051
"models",
5152
{

docs/plan-mode.mdx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
---
2+
title: Plan Mode
3+
description: Review and collaborate on plans before execution
4+
---
5+
6+
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.
7+
8+
## How It Works
9+
10+
1. **Toggle to Plan Mode**: Press `Cmd+Shift+M` (Mac) or `Ctrl+Shift+M` (Windows/Linux), or use the mode switcher in the UI.
11+
12+
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.
13+
14+
3. **Propose for Review**: When ready, the agent calls `propose_plan` to present the plan in the chat UI with rendered markdown.
15+
16+
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.
17+
18+
5. **Iterate or Execute**: Provide feedback in chat, or switch to Exec mode (`Cmd+Shift+M`) to implement the plan.
19+
20+
## External Edit Detection
21+
22+
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:
23+
24+
1. **State Tracking**: When `propose_plan` runs, it records the plan file's content and modification time.
25+
2. **Change Detection**: Before each LLM query, mux checks if the file's mtime has changed.
26+
3. **Diff Injection**: If modified, mux computes a diff and injects it into the context so the agent sees exactly what changed.
27+
28+
This means you can make edits in your preferred editor, return to mux, send a message, and the agent will incorporate your changes.
29+
30+
## Plan File Location
31+
32+
Plans are stored in a dedicated directory:
33+
34+
```
35+
~/.mux/plans/<workspace-id>.md
36+
```
37+
38+
The file is created when the agent first writes a plan and persists across sessions.
39+
40+
## UI Features
41+
42+
The `propose_plan` tool call in chat includes:
43+
44+
- **Rendered Markdown**: View the plan with proper formatting.
45+
- **Edit Button**: Opens the plan file in your external editor (latest plan only).
46+
- **Copy Button**: Copy plan content to clipboard.
47+
- **Show Text/Markdown Toggle**: Switch between rendered and raw views.
48+
- **Start Here**: Replace chat history with this plan as context (useful for long sessions).
49+
50+
## Workflow Example
51+
52+
```
53+
User: "Add user authentication to the app"
54+
55+
56+
┌─────────────────────────────────────┐
57+
│ Agent reads codebase, writes plan │
58+
│ to ~/.mux/plans/<id>.md │
59+
└─────────────────────────────────────┘
60+
61+
62+
┌─────────────────────────────────────┐
63+
│ Agent calls propose_plan │
64+
│ (plan displayed in chat) │
65+
└─────────────────────────────────────┘
66+
67+
├─────────────────────────────────┐
68+
│ │
69+
▼ ▼
70+
┌─────────────────────┐ ┌─────────────────────────┐
71+
│ User provides │ │ User clicks "Edit" │
72+
│ feedback in chat │ │ → edits in nvim/vscode │
73+
└─────────────────────┘ └─────────────────────────┘
74+
│ │
75+
│ │ (external edits)
76+
│ ▼
77+
│ ┌─────────────────────────┐
78+
│ │ User sends message │
79+
│ │ → mux detects changes │
80+
│ │ → diff injected │
81+
│ └─────────────────────────┘
82+
│ │
83+
▼ ▼
84+
┌─────────────────────────────────────────────────────────┐
85+
│ Agent revises plan based on feedback │
86+
│ (cycles back to propose_plan) │
87+
└─────────────────────────────────────────────────────────┘
88+
89+
│ (when satisfied)
90+
91+
┌─────────────────────────────────────┐
92+
│ User switches to Exec mode │
93+
│ (Cmd+Shift+M) │
94+
└─────────────────────────────────────┘
95+
96+
97+
┌─────────────────────────────────────┐
98+
│ Agent implements the plan │
99+
└─────────────────────────────────────┘
100+
```
101+
102+
## Customizing Plan Mode Behavior
103+
104+
Use [scoped instructions](/instruction-files) to customize how the agent behaves in plan mode:
105+
106+
```markdown
107+
## Mode: Plan
108+
109+
When planning:
110+
111+
- Focus on goals and trade-offs
112+
- Propose alternatives with pros/cons
113+
- Attach LoC estimates to each approach
114+
```
115+
116+
## CLI Usage
117+
118+
Plan mode is also available via the CLI:
119+
120+
```bash
121+
mux run --mode plan "Design a caching strategy for the API"
122+
```
123+
124+
See [CLI documentation](/cli) for more options.

src/browser/components/AIView.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
445445
)?.historyId
446446
: undefined;
447447

448+
// Find the ID of the latest propose_plan tool call for external edit detection
449+
// Only the latest plan should fetch fresh content from disk
450+
let latestProposePlanId: string | null = null;
451+
for (let i = mergedMessages.length - 1; i >= 0; i--) {
452+
const msg = mergedMessages[i];
453+
if (msg.type === "tool" && msg.toolName === "propose_plan") {
454+
latestProposePlanId = msg.id;
455+
break;
456+
}
457+
}
458+
448459
if (loading) {
449460
return (
450461
<div
@@ -557,6 +568,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
557568
workspaceId={workspaceId}
558569
isCompacting={isCompacting}
559570
onReviewNote={handleReviewNote}
571+
isLatestProposePlan={
572+
msg.type === "tool" &&
573+
msg.toolName === "propose_plan" &&
574+
msg.id === latestProposePlanId
575+
}
560576
/>
561577
</div>
562578
{isAtCutoff && (

src/browser/components/ChatInput/index.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
import {
3232
handleNewCommand,
3333
handleCompactCommand,
34+
handlePlanShowCommand,
35+
handlePlanOpenCommand,
3436
forkWorkspace,
3537
prepareCompactionMessage,
3638
executeCompaction,
@@ -933,6 +935,35 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
933935
return;
934936
}
935937

938+
// Handle /plan command
939+
if (parsed.type === "plan-show" || parsed.type === "plan-open") {
940+
if (!api) {
941+
setToast({
942+
id: Date.now().toString(),
943+
type: "error",
944+
message: "Not connected to server",
945+
});
946+
return;
947+
}
948+
const context: CommandHandlerContext = {
949+
api: api,
950+
workspaceId: props.workspaceId,
951+
sendMessageOptions,
952+
setInput,
953+
setImageAttachments,
954+
setIsSending,
955+
setToast,
956+
};
957+
958+
const handler =
959+
parsed.type === "plan-show" ? handlePlanShowCommand : handlePlanOpenCommand;
960+
const result = await handler(context);
961+
if (!result.clearInput) {
962+
setInput(messageText); // Restore input on error
963+
}
964+
return;
965+
}
966+
936967
// Handle all other commands - show display toast
937968
const commandToast = createCommandToast(parsed);
938969
if (commandToast) {

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,8 @@ function createDraftSettingsHarness(
527527
}>
528528
) {
529529
const state = {
530-
runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode),
531-
defaultRuntimeMode: initial?.defaultRuntimeMode ?? ("worktree" as RuntimeMode),
530+
runtimeMode: initial?.runtimeMode ?? "local",
531+
defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree",
532532
sshHost: initial?.sshHost ?? "",
533533
trunkBranch: initial?.trunkBranch ?? "main",
534534
runtimeString: initial?.runtimeString,

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { ReasoningMessage } from "./ReasoningMessage";
88
import { StreamErrorMessage } from "./StreamErrorMessage";
99
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
1010
import { InitMessage } from "./InitMessage";
11+
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
12+
import { removeEphemeralMessage } from "@/browser/stores/WorkspaceStore";
1113

1214
interface MessageRendererProps {
1315
message: DisplayedMessage;
@@ -18,11 +20,21 @@ interface MessageRendererProps {
1820
isCompacting?: boolean;
1921
/** Handler for adding review notes from inline diffs */
2022
onReviewNote?: (data: ReviewNoteData) => void;
23+
/** Whether this message is the latest propose_plan tool call (for external edit detection) */
24+
isLatestProposePlan?: boolean;
2125
}
2226

2327
// Memoized to prevent unnecessary re-renders when parent (AIView) updates
2428
export const MessageRenderer = React.memo<MessageRendererProps>(
25-
({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => {
29+
({
30+
message,
31+
className,
32+
onEditUserMessage,
33+
workspaceId,
34+
isCompacting,
35+
onReviewNote,
36+
isLatestProposePlan,
37+
}) => {
2638
// Route based on message type
2739
switch (message.type) {
2840
case "user":
@@ -50,6 +62,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
5062
className={className}
5163
workspaceId={workspaceId}
5264
onReviewNote={onReviewNote}
65+
isLatestProposePlan={isLatestProposePlan}
5366
/>
5467
);
5568
case "reasoning":
@@ -60,6 +73,22 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
6073
return <HistoryHiddenMessage message={message} className={className} />;
6174
case "workspace-init":
6275
return <InitMessage message={message} className={className} />;
76+
case "plan-display":
77+
return (
78+
<ProposePlanToolCall
79+
args={{}}
80+
isEphemeralPreview={true}
81+
content={message.content}
82+
path={message.path}
83+
workspaceId={workspaceId}
84+
onClose={() => {
85+
if (workspaceId) {
86+
removeEphemeralMessage(workspaceId, message.historyId);
87+
}
88+
}}
89+
className={className}
90+
/>
91+
);
6392
default:
6493
console.error("don't know how to render message", message);
6594
return null;

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ interface ToolMessageProps {
4343
workspaceId?: string;
4444
/** Handler for adding review notes from inline diffs */
4545
onReviewNote?: (data: ReviewNoteData) => void;
46+
/** Whether this is the latest propose_plan in the conversation */
47+
isLatestProposePlan?: boolean;
4648
}
4749

4850
// Type guards using Zod schemas for single source of truth
@@ -116,6 +118,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
116118
className,
117119
workspaceId,
118120
onReviewNote,
121+
isLatestProposePlan,
119122
}) => {
120123
// Route to specialized components based on tool name
121124
if (isBashTool(message.toolName, message.args)) {
@@ -193,6 +196,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
193196
result={message.result as ProposePlanToolResult | undefined}
194197
status={message.status}
195198
workspaceId={workspaceId}
199+
isLatest={isLatestProposePlan}
196200
/>
197201
</div>
198202
);

0 commit comments

Comments
 (0)