Skip to content

Conversation

@ThomasK33
Copy link
Member

Adds a new interactive plan-mode tool ask_user_question (inspired by Claude Code's AskUserQuestion) and a corresponding inline UI for answering multiple-choice questions.

Key behaviors:

  • Tool is only registered/available in plan mode.
  • While the tool is pending, the UI shows an inline multi-question form.
  • If the user types a normal chat message instead, the tool is canceled (recorded as a failed tool call via tool-error) and the message is sent as a real user message.
  • Tool args/results are inferred from Zod schemas to avoid drift.

Validation:

  • make static-check

📋 Implementation Plan

🤖 Plan: Add an AskUserQuestion-style tool to Mux (Plan Mode UX)

Goal

Implement a tool compatible with Claude Code’s AskUserQuestion semantics (per cc-plan-mode.md) so that, while staying in plan mode, the assistant can ask the user 1–4 multiple-choice questions and receive structured answers.

Key UX requirement: the question UI should be non-invasive and optional—the user is encouraged to answer via the form, but they should also be able to just type a normal chat reply without having to explicitly “close/deny” the UI.

Source-of-truth behavior to mirror (from cc-plan-mode.md)

We should match the Claude Code behavior + schema as closely as practical, while using Mux’s snake_case naming convention:

  • Tool name (CC): AskUserQuestion
  • Tool name (Mux): ask_user_question
  • Input: { questions: Question[], answers?: Record<string,string> }
  • Output: { questions: Question[], answers: Record<string,string> }
    • answers map: question text → answer string
    • multi-select answers are comma-separated
  • Constraints:
    • questions: 1–4
    • options per question: 2–4
    • question texts unique; option labels unique within a question
    • “Other” is always available (but not included in options input)

What already exists in Mux (relevant implementation hooks)

  • Tool call rendering is centralized in src/browser/components/Messages/ToolMessage.tsx, which routes known tools to custom React components; otherwise it falls back to GenericToolCall.
  • Tool UI primitives are in src/browser/components/tools/shared/ToolPrimitives.tsx + toolUtils.tsx.
  • We already have reusable UI building blocks:
    • src/browser/components/ui/toggle-group.tsx (Radix ToggleGroup; supports type="single"|"multiple")
    • src/browser/components/ui/checkbox.tsx
    • src/browser/components/ui/input.tsx
    • src/browser/components/ui/button.tsx
  • Tool definitions live in src/common/utils/tools/toolDefinitions.ts (Zod schema + description); backend tools reference this.
  • Backend tools are in src/node/services/tools/* and are registered through src/common/utils/tools/tools.ts (getToolsForModel).
  • Streaming tool calls are represented as dynamic-tool parts; while a tool is running it’s state: "input-available" and UI status becomes "executing".

Recommended approach (single approach; net new product LoC ~500–900)

Summary

Add a new interactive tool ask_user_question (CC-compatible AskUserQuestion) that:

  1. Pauses the current assistant stream until answers are provided.
  2. Renders an inline, collapsible multiple-choice form in the chat (like other tool calls).
  3. Lets the user either:
    • submit answers via the form (structured), or
    • type a normal chat message, which cancels the question tool call and is sent as a real user message (no “deny/close” step).

Backend design

1) Tool definition (shared)

  • Add ask_user_question to TOOL_DEFINITIONS with a Zod schema matching cc-plan-mode.md:
    • Prefer defining and exporting named sub-schemas (e.g. AskUserQuestionOptionSchema, AskUserQuestionQuestionSchema) so the result schema + UI types can reuse them.
    • Option: { label: string; description: string }
    • Question: { question: string; header: string; options: Option[]; multiSelect: boolean }
    • Input: { questions: Question[]; answers?: Record<string,string> } + refine uniqueness
    • Output: { questions: Question[]; answers: Record<string,string> }

TypeScript types: infer from Zod (avoid handwritten interfaces) to prevent drift.

  • Args:
    • export type AskUserQuestionToolArgs = z.infer<typeof TOOL_DEFINITIONS.ask_user_question.schema>;
  • Result:
    • Define a shared AskUserQuestionToolResultSchema (Zod) alongside the input schema (reusing the same Question/Option schemas), then:
    • export type AskUserQuestionToolResult = z.infer<typeof AskUserQuestionToolResultSchema>;
  • (Optional) also export AskUserQuestionQuestion / AskUserQuestionOption via z.infer<...> for the UI reducer.

2) Pending-question manager (new node service)

Create an in-memory manager owned by the backend process (NOT persisted):

  • AskUserQuestionManager
    • registerPending(workspaceId, toolCallId, questions): Promise<Record<string,string>>
    • answer(workspaceId, toolCallId, answers): void
    • cancel(workspaceId, toolCallId, reason): void
    • getLatestPending(workspaceId): { toolCallId, questions } | null

Important defensive behavior:

  • Assert toolCallIds are unique per workspace.
  • Always delete pending entries on resolve/reject.
  • Add a long timeout (e.g., 30 minutes) to avoid leaks if the UI never responds.
  • Support abort: if the stream is interrupted, tool execution must unblock (reject) so interrupt actually works.

3) Tool implementation (src/node/services/tools/ask_user_question.ts)

Implement createAskUserQuestionTool(config) using tool() from ai.

Execution flow:

  • Validate args using the shared Zod schema.
  • Determine workspaceId from config.workspaceId (assert present).
  • Determine toolCallId from ToolCallOptions.toolCallId.
  • Register the pending question with AskUserQuestionManager and await answers.
  • Return { questions, answers } (matching Claude Code’s output schema).

Abort/cancel handling:

  • Race the awaited promise against an abort signal if available.
  • On abort/interrupt/cancel (including “user responded in chat” cancellation), throw an Error so the AI SDK emits a tool-error part (Mux will record this as a failed tool result).

Model-context parity (optional but recommended):

  • Mux providers will see tool output as structured JSON by default.
  • To be more CC-like (and more token-efficient), add a small transform in applyToolOutputRedaction(...) to replace the provider-facing output for ask_user_question with a short string summary like:
    • User has answered your questions: "<Q>"="<A>", ... You can now continue with the user's answers in mind.
    • while keeping the full structured { questions, answers } persisted for UI.

4) ORPC endpoint to submit answers

Add a workspace-scoped ORPC endpoint:

  • workspace.answerAskUserQuestion
    • input: { workspaceId: string; toolCallId: string; answers: Record<string,string> }
    • output: Result<void, string>

Implementation:

  • Route → WorkspaceServiceAskUserQuestionManager.answer(...).

5) “Chat message instead of clicking” behavior (non-invasive requirement)

Requirement (confirmed): if the user just types a normal chat reply while ask_user_question is pending, we should not treat it as tool input. Instead we should:

  1. Cancel the tool call (so it shows as failed with a message like “User responded in chat; questions canceled”).
  2. Send the user’s text as a real user message (normal send/queue semantics).

Implementation sketch:

  • Add AskUserQuestionManager.getLatestPending(workspaceId) (or getPendingByToolCallId) that returns the active pending prompt.
  • Modify WorkspaceService.sendMessage() behavior:
    • if aiService.isStreaming(workspaceId) and a pending ask_user_question exists:
      • call AskUserQuestionManager.cancel(workspaceId, toolCallId, "User responded in chat; questions canceled")
      • then proceed with existing behavior (queue message via session.queueMessage(message, options))
      • return Ok()

Effect:

  • The stream unblocks because the tool throws → AI SDK emits tool-error → Mux records a failed tool result.
  • The user’s message remains a normal user message and will be processed next (queued during streaming, then auto-sent on tool completion / stream end, per existing queue behavior).

Frontend design

1) Add a custom tool renderer

Add AskUserQuestionToolCall.tsx under src/browser/components/tools/ and route it in ToolMessage.tsx similarly to ProposePlanToolCall.

Props needed:

  • args, result, status, workspaceId

2) UI behavior (inline form)

Render as an inline tool card, expanded by default while awaiting answers:

  • Header: ask_user_question + status
  • Body:
    • Tab bar for 1–4 questions (mirroring CC’s chips): show each header, with a checkmark for answered.
    • Per-question view:
      • question text
      • options:
        • single-select → ToggleGroup type="single"
        • multi-select → Checkbox list or ToggleGroup type="multiple"
      • “Other”:
        • option always available
        • selecting “Other” reveals an Input for custom text
    • Submit view:
      • show all answers in a review list
      • Submit answers button → calls api.workspace.answerAskUserQuestion({ workspaceId, toolCallId, answers })

State management:

  • Use a reducer similar to CC’s (currentQuestionIndex, answers, questionStates, isInTextInput).

Accessibility/keyboard (follow-up, but planned):

  • Basic: click-based + tab focus.
  • Optional parity: left/right arrow to navigate question tabs; enter to select.

3) Display after completion

When result is present:

  • Show a compact “User answered …” summary (like CC)
  • Keep details collapsible.

4) Non-invasive encouragement

While the tool is pending:

  • Add a small helper line in the tool body: “Tip: you can also just type a message to respond in chat (this will cancel these questions).”

(We can later refine this into a better “waiting for input” streaming banner, but it’s not required for v1.)

Testing / validation (net new test LoC ~250–450)

  • Unit tests for AskUserQuestionManager:
    • registers + answers
    • cancel/timeout cleanup
    • rejects on abort
  • Backend test for ORPC endpoint (or WorkspaceService method): answering resolves the tool.
  • Storybook:
    • Add a chat story with a pending ask_user_question tool call
    • Add a story with completed answers

Alternatives considered

A) Non-blocking “suggestion card” tool (net new product LoC ~300–600)

Have ask_user_question return immediately and not pause the stream; user answers later and the answer is posted as a normal user message.

Downside: this diverges from Claude Code semantics and won’t behave like the tool LLMs were trained on (tool answers won’t be available in the same turn).

Decisions / requirements confirmed

  • If the user types a chat message while ask_user_question is pending:
    • mark the tool call as failed with an output indicating the user canceled by responding in chat
    • then treat the typed text as a real user message (normal queue/send behavior)
  • Tool name in Mux should be ask_user_question (snake_case).
  • No explicit “Skip” button required; a small helper label is enough.
  • No feature flags: include this tool + UI by default in plan mode.

Availability / gating

  • Register ask_user_question only when SendMessageOptions.mode === "plan" (so it is available by default in plan mode, and not callable in exec mode).
  • Ensure AIService.streamMessage() passes mode into the early getToolsForModel(...) call used for the mode-transition “Available tools: …” sentinel. Otherwise plan mode would omit ask_user_question from the advertised tool list even though it’s registered.
  • UI: always render custom tool UI for ask_user_question tool parts (no toggle).

Net new product code estimate (recommended approach): ~500–900 LoC


Generated with mux

@ThomasK33 ThomasK33 force-pushed the ask-user-question-tool branch 2 times, most recently from 88f7e4f to 099caea Compare December 12, 2025 19:22
Change-Id: I84e7c504ff312915a9e0fdeae4e6d4e30e1d9aa1
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I89e5cf1a21b7fd491b7f821870ddeada88be9f39
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I9aa88d6979c49c8e68926f85e7bc82421d67dca9
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33 ThomasK33 force-pushed the ask-user-question-tool branch from d68703c to 69c5719 Compare December 12, 2025 21:21
Change-Id: I89dc9db6cfc514869bcb6587c1b80e3511b72bf7
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant