Skip to content
29 changes: 25 additions & 4 deletions docs/plan-mode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,34 @@ This means you can make edits in your preferred editor, return to mux, send a me

## Plan File Location

Plans are stored in a dedicated directory:
Plans are stored in a dedicated directory under your Mux home:

```
~/.mux/plans/<workspace-id>.md
~/.mux/plans/<project>/<workspace-name>.md
```

The file is created when the agent first writes a plan and persists across sessions.
Notes:

- `<workspace-name>` includes the random suffix (e.g. `feature-x7k2`), so it’s globally unique with high probability.

## ask_user_question (Plan Mode Only)

In plan mode, the agent may call `ask_user_question` to ask up to 4 structured multiple-choice questions when it needs clarification before finalizing a plan.

What you’ll see:

- An inline “tool call card” in the chat with a small form (single-select or multi-select).
- An always-available **Other** option for free-form answers.

How to respond:

- **Recommended:** answer in the form and click **Submit answers**.
- **Optional:** you can also just type a normal chat message. This will **cancel** the pending `ask_user_question` tool call and your message will be sent as a regular chat message.

Availability:

- `ask_user_question` is only registered for the agent in **Plan Mode**.
- In Exec Mode, the agent cannot call `ask_user_question`.

## UI Features

Expand All @@ -55,7 +76,7 @@ User: "Add user authentication to the app"
┌─────────────────────────────────────┐
│ Agent reads codebase, writes plan │
│ to ~/.mux/plans/<id>.md
│ to ~/.mux/plans/<project>/<name>.md│
└─────────────────────────────────────┘
Expand Down
41 changes: 26 additions & 15 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
useEffect(() => {
workspaceStateRef.current = workspaceState;
}, [workspaceState]);
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
const { messages, canInterrupt, isCompacting, awaitingUserQuestion, loading, currentModel } =
workspaceState;

// Apply message transformations:
// 1. Merge consecutive identical stream errors
Expand Down Expand Up @@ -673,24 +674,34 @@ const AIViewInner: React.FC<AIViewProps> = ({
{canInterrupt && (
<StreamingBarrier
statusText={
isCompacting
? currentModel
? `${getModelName(currentModel)} compacting...`
: "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
awaitingUserQuestion
? "Awaiting your input..."
: isCompacting
? currentModel
? `${getModelName(currentModel)} compacting...`
: "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
cancelText={
awaitingUserQuestion
? "type a message to respond"
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
}
cancelText={`hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`}
tokenCount={
activeStreamMessageId
? aggregator?.getStreamingTokenCount(activeStreamMessageId)
: undefined
awaitingUserQuestion
? undefined
: activeStreamMessageId
? aggregator?.getStreamingTokenCount(activeStreamMessageId)
: undefined
}
tps={
activeStreamMessageId
? aggregator?.getStreamingTPS(activeStreamMessageId)
: undefined
awaitingUserQuestion
? undefined
: activeStreamMessageId
? aggregator?.getStreamingTPS(activeStreamMessageId)
: undefined
}
/>
)}
Expand Down
22 changes: 22 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GenericToolCall } from "../tools/GenericToolCall";
import { BashToolCall } from "../tools/BashToolCall";
import { FileEditToolCall } from "../tools/FileEditToolCall";
import { FileReadToolCall } from "../tools/FileReadToolCall";
import { AskUserQuestionToolCall } from "../tools/AskUserQuestionToolCall";
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
import { TodoToolCall } from "../tools/TodoToolCall";
import { StatusSetToolCall } from "../tools/StatusSetToolCall";
Expand All @@ -29,6 +30,8 @@ import type {
FileEditReplaceStringToolResult,
FileEditReplaceLinesToolArgs,
FileEditReplaceLinesToolResult,
AskUserQuestionToolArgs,
AskUserQuestionToolResult,
ProposePlanToolArgs,
ProposePlanToolResult,
TodoWriteToolArgs,
Expand Down Expand Up @@ -90,6 +93,11 @@ function isFileEditInsertTool(toolName: string, args: unknown): args is FileEdit
return TOOL_DEFINITIONS.file_edit_insert.schema.safeParse(args).success;
}

function isAskUserQuestionTool(toolName: string, args: unknown): args is AskUserQuestionToolArgs {
if (toolName !== "ask_user_question") return false;
return TOOL_DEFINITIONS.ask_user_question.schema.safeParse(args).success;
}

function isProposePlanTool(toolName: string, args: unknown): args is ProposePlanToolArgs {
if (toolName !== "propose_plan") return false;
return TOOL_DEFINITIONS.propose_plan.schema.safeParse(args).success;
Expand Down Expand Up @@ -213,6 +221,20 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
);
}

if (isAskUserQuestionTool(message.toolName, message.args)) {
return (
<div className={className}>
<AskUserQuestionToolCall
args={message.args}
result={(message.result as AskUserQuestionToolResult | undefined) ?? null}
status={message.status}
toolCallId={message.toolCallId}
workspaceId={workspaceId}
/>
</div>
);
}

if (isProposePlanTool(message.toolName, message.args)) {
return (
<div className={className}>
Expand Down
9 changes: 5 additions & 4 deletions src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
}
};

const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);
const isWorking = canInterrupt && !awaitingUserQuestion;

return (
<React.Fragment>
Expand Down Expand Up @@ -167,7 +168,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
<TooltipContent align="start">Remove workspace</TooltipContent>
</Tooltip>
)}
<RuntimeBadge runtimeConfig={metadata.runtimeConfig} isWorking={canInterrupt} />
<RuntimeBadge runtimeConfig={metadata.runtimeConfig} isWorking={isWorking} />
{isEditing ? (
<input
className="bg-input-bg text-input-text border-input-border font-inherit focus:border-input-border-focus col-span-2 min-w-0 flex-1 rounded-sm border px-1 text-left text-[13px] outline-none"
Expand Down Expand Up @@ -195,7 +196,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
}}
title={isDisabled ? undefined : "Double-click to edit title"}
>
{canInterrupt || isCreating ? (
{isWorking || isCreating ? (
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
{displayTitle}
</Shimmer>
Expand All @@ -213,7 +214,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="right"
isWorking={canInterrupt}
isWorking={isWorking}
/>
)}
</div>
Expand Down
7 changes: 4 additions & 3 deletions src/browser/components/WorkspaceStatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ export const WorkspaceStatusDot = memo<{
size?: number;
}>(
({ workspaceId, lastReadTimestamp, onClick, size = 8 }) => {
const { canInterrupt, currentModel, agentStatus, recencyTimestamp } =
const { canInterrupt, awaitingUserQuestion, currentModel, agentStatus, recencyTimestamp } =
useWorkspaceSidebarState(workspaceId);

const streaming = canInterrupt;
const streaming = canInterrupt && !awaitingUserQuestion;

// Compute unread status if lastReadTimestamp provided (sidebar only)
const unread = useMemo(() => {
Expand All @@ -27,12 +27,13 @@ export const WorkspaceStatusDot = memo<{
() =>
getStatusTooltip({
isStreaming: streaming,
isAwaitingInput: awaitingUserQuestion,
streamingModel: currentModel,
agentStatus,
isUnread: unread,
recencyTimestamp,
}),
[streaming, currentModel, agentStatus, unread, recencyTimestamp]
[streaming, awaitingUserQuestion, currentModel, agentStatus, unread, recencyTimestamp]
);

const bgColor = canInterrupt ? "bg-blue-400" : unread ? "bg-gray-300" : "bg-muted-dark";
Expand Down
12 changes: 11 additions & 1 deletion src/browser/components/WorkspaceStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { Button } from "./ui/button";

export const WorkspaceStatusIndicator = memo<{ workspaceId: string }>(({ workspaceId }) => {
const { agentStatus } = useWorkspaceSidebarState(workspaceId);
const { agentStatus, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);

// Show prompt when ask_user_question is pending - make it prominent
if (awaitingUserQuestion) {
return (
<div className="flex min-w-0 items-center gap-1.5 rounded bg-yellow-500/20 px-1.5 py-0.5 text-xs text-yellow-400">
<span className="-mt-0.5 shrink-0 text-[10px]">❓</span>
<span className="min-w-0 truncate font-medium">Mux has a few questions</span>
</div>
);
}

if (!agentStatus) {
return null;
Expand Down
Loading
Loading