diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index f3a8da81f..0b6b591fe 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -49,6 +49,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClient } from "@server/client/daemon-client";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
+import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -1070,6 +1071,16 @@ function PermissionRequestCard({
[permission.agentId, permission.request.id, respondToPermission]
);
+ if (request.kind === "question") {
+ return (
+
+ );
+ }
+
return (
-
- {({ hovered }) => {
- const showCopyButton = Platform.OS !== "web" || hovered;
- return (
- <>
-
-
- {message}
-
-
- message}
- containerStyle={[
- userMessageStylesheet.copyButton,
- showCopyButton
- ? userMessageStylesheet.copyButtonVisible
- : userMessageStylesheet.copyButtonHidden,
- ]}
- accessibilityLabel="Copy message"
- />
- >
- );
- }}
+ setMessageHovered(true) : undefined
+ }
+ onHoverOut={
+ Platform.OS === "web" ? () => setMessageHovered(false) : undefined
+ }
+ >
+
+
+ {message}
+
+
+ message}
+ containerStyle={[
+ userMessageStylesheet.copyButton,
+ showCopyButton
+ ? userMessageStylesheet.copyButtonVisible
+ : userMessageStylesheet.copyButtonHidden,
+ ]}
+ accessibilityLabel="Copy message"
+ onHoverChange={setCopyButtonHovered}
+ />
);
@@ -251,6 +257,7 @@ interface TurnCopyButtonProps {
containerStyle?: StyleProp;
accessibilityLabel?: string;
copiedAccessibilityLabel?: string;
+ onHoverChange?: (hovered: boolean) => void;
}
export const TurnCopyButton = memo(function TurnCopyButton({
@@ -258,6 +265,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
containerStyle,
accessibilityLabel,
copiedAccessibilityLabel,
+ onHoverChange,
}: TurnCopyButtonProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef | null>(null);
@@ -292,6 +300,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return (
onHoverChange?.(true) : undefined}
+ onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx
new file mode 100644
index 000000000..0253b934a
--- /dev/null
+++ b/packages/app/src/components/question-form-card.tsx
@@ -0,0 +1,409 @@
+import { useState, useCallback } from "react";
+import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { Check, X, Send } from "lucide-react-native";
+import type { PendingPermission } from "@/types/shared";
+import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
+
+interface QuestionOption {
+ label: string;
+ description?: string;
+}
+
+interface Question {
+ question: string;
+ header: string;
+ options: QuestionOption[];
+ multiSelect: boolean;
+}
+
+function parseQuestions(input: unknown): Question[] | null {
+ if (
+ typeof input !== "object" ||
+ input === null ||
+ !("questions" in input) ||
+ !Array.isArray((input as Record).questions)
+ ) {
+ return null;
+ }
+ const raw = (input as Record).questions as unknown[];
+ const questions: Question[] = [];
+ for (const item of raw) {
+ if (typeof item !== "object" || item === null) return null;
+ const q = item as Record;
+ if (typeof q.question !== "string" || typeof q.header !== "string") return null;
+ if (!Array.isArray(q.options)) return null;
+ const options: QuestionOption[] = [];
+ for (const opt of q.options as unknown[]) {
+ if (typeof opt !== "object" || opt === null) return null;
+ const o = opt as Record;
+ if (typeof o.label !== "string") return null;
+ options.push({
+ label: o.label,
+ description: typeof o.description === "string" ? o.description : undefined,
+ });
+ }
+ questions.push({
+ question: q.question,
+ header: q.header,
+ options,
+ multiSelect: q.multiSelect === true,
+ });
+ }
+ return questions.length > 0 ? questions : null;
+}
+
+interface QuestionFormCardProps {
+ permission: PendingPermission;
+ onRespond: (response: AgentPermissionResponse) => void;
+ isResponding: boolean;
+}
+
+export function QuestionFormCard({
+ permission,
+ onRespond,
+ isResponding,
+}: QuestionFormCardProps) {
+ const { theme } = useUnistyles();
+ const questions = parseQuestions(permission.request.input);
+
+ // selections[questionIndex] = Set of selected option indices
+ const [selections, setSelections] = useState>>({});
+ // otherTexts[questionIndex] = custom "Other" text
+ const [otherTexts, setOtherTexts] = useState>({});
+
+ const toggleOption = useCallback(
+ (qIndex: number, optIndex: number, multiSelect: boolean) => {
+ setSelections((prev) => {
+ const current = prev[qIndex] ?? new Set();
+ const next = new Set(current);
+ if (multiSelect) {
+ if (next.has(optIndex)) {
+ next.delete(optIndex);
+ } else {
+ next.add(optIndex);
+ }
+ } else {
+ if (next.has(optIndex)) {
+ next.clear();
+ } else {
+ next.clear();
+ next.add(optIndex);
+ }
+ }
+ return { ...prev, [qIndex]: next };
+ });
+ // Clear "Other" text when an option is selected
+ setOtherTexts((prev) => {
+ if (!prev[qIndex]) return prev;
+ const next = { ...prev };
+ delete next[qIndex];
+ return next;
+ });
+ },
+ []
+ );
+
+ const setOtherText = useCallback((qIndex: number, text: string) => {
+ setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
+ // Clear option selections when typing "Other"
+ if (text.length > 0) {
+ setSelections((prev) => {
+ if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
+ return { ...prev, [qIndex]: new Set() };
+ });
+ }
+ }, []);
+
+ if (!questions) {
+ return null;
+ }
+
+ const allAnswered = questions.every((_, qIndex) => {
+ const selected = selections[qIndex];
+ const otherText = otherTexts[qIndex]?.trim();
+ return (selected && selected.size > 0) || (otherText && otherText.length > 0);
+ });
+
+ function handleSubmit() {
+ const answers: Record = {};
+ for (let i = 0; i < questions!.length; i++) {
+ const q = questions![i];
+ const selected = selections[i];
+ const otherText = otherTexts[i]?.trim();
+
+ if (otherText && otherText.length > 0) {
+ answers[q.header] = otherText;
+ } else if (selected && selected.size > 0) {
+ const labels = Array.from(selected).map((idx) => q.options[idx].label);
+ answers[q.header] = labels.join(", ");
+ }
+ }
+
+ onRespond({
+ behavior: "allow",
+ updatedInput: { ...permission.request.input, answers },
+ });
+ }
+
+ function handleDeny() {
+ onRespond({
+ behavior: "deny",
+ message: "Dismissed by user",
+ });
+ }
+
+ return (
+
+ {questions.map((q, qIndex) => {
+ const selected = selections[qIndex] ?? new Set();
+ const otherText = otherTexts[qIndex] ?? "";
+
+ return (
+
+
+ {q.header}
+
+
+ {q.question}
+
+
+ {q.options.map((opt, optIndex) => {
+ const isSelected = selected.has(optIndex);
+ return (
+ {
+ const hovered = Boolean((state as any).hovered);
+ return [
+ styles.chip,
+ {
+ borderColor: isSelected
+ ? theme.colors.accent
+ : theme.colors.border,
+ backgroundColor: isSelected
+ ? `${theme.colors.accent}18`
+ : hovered
+ ? theme.colors.surface1
+ : theme.colors.surface2,
+ },
+ ];
+ }}
+ onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
+ disabled={isResponding}
+ >
+
+ {q.multiSelect && isSelected ? (
+
+ ) : null}
+
+ {opt.label}
+
+
+ {opt.description ? (
+
+ {opt.description}
+
+ ) : null}
+
+ );
+ })}
+
+ 0
+ ? theme.colors.accent
+ : theme.colors.border,
+ color: theme.colors.foreground,
+ backgroundColor: theme.colors.surface0,
+ },
+ ]}
+ placeholder="Other..."
+ placeholderTextColor={theme.colors.foregroundMuted}
+ value={otherText}
+ onChangeText={(text) => setOtherText(qIndex, text)}
+ editable={!isResponding}
+ />
+
+ );
+ })}
+
+
+ {
+ const hovered = Boolean((state as any).hovered);
+ return [
+ styles.actionButton,
+ {
+ backgroundColor: hovered
+ ? theme.colors.surface1
+ : theme.colors.surface2,
+ borderColor: theme.colors.border,
+ },
+ ];
+ }}
+ onPress={handleDeny}
+ disabled={isResponding}
+ >
+ {isResponding ? (
+
+ ) : (
+
+
+
+ Dismiss
+
+
+ )}
+
+
+ {
+ const hovered = Boolean((state as any).hovered);
+ const disabled = !allAnswered || isResponding;
+ return [
+ styles.actionButton,
+ {
+ backgroundColor: hovered && !disabled
+ ? theme.colors.surface1
+ : theme.colors.surface2,
+ borderColor: disabled
+ ? theme.colors.border
+ : theme.colors.accent,
+ opacity: disabled ? 0.5 : 1,
+ },
+ ];
+ }}
+ onPress={handleSubmit}
+ disabled={!allAnswered || isResponding}
+ >
+ {isResponding ? (
+
+ ) : (
+
+
+
+ Submit
+
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ marginVertical: theme.spacing[3],
+ padding: theme.spacing[3],
+ borderRadius: theme.spacing[2],
+ borderWidth: 1,
+ gap: theme.spacing[3],
+ },
+ questionBlock: {
+ gap: theme.spacing[2],
+ },
+ header: {
+ fontSize: theme.fontSize.xs,
+ fontWeight: theme.fontWeight.semibold,
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ questionText: {
+ fontSize: theme.fontSize.base,
+ fontWeight: theme.fontWeight.medium,
+ },
+ optionsWrap: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: theme.spacing[2],
+ },
+ chip: {
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ borderWidth: theme.borderWidth[1],
+ gap: theme.spacing[1],
+ },
+ chipContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ },
+ chipLabel: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ },
+ chipDescription: {
+ fontSize: theme.fontSize.xs,
+ lineHeight: 16,
+ },
+ otherInput: {
+ borderWidth: theme.borderWidth[1],
+ borderRadius: theme.borderRadius.md,
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ fontSize: theme.fontSize.sm,
+ },
+ actions: {
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ marginTop: theme.spacing[1],
+ },
+ actionButton: {
+ flex: 1,
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ alignItems: "center",
+ borderWidth: theme.borderWidth[1],
+ },
+ actionContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ actionText: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.normal,
+ },
+}));
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
index e36e155ce..1f7e26b55 100644
--- a/packages/server/src/server/agent/agent-sdk-types.ts
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -165,7 +165,7 @@ export type AgentStreamEvent =
timestamp: string;
};
-export type AgentPermissionRequestKind = "tool" | "plan" | "mode" | "other";
+export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
export type AgentPermissionUpdate = AgentMetadata;
diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts
index 7168c9751..e44127a0c 100644
--- a/packages/server/src/server/agent/providers/claude-agent.test.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.test.ts
@@ -1247,6 +1247,36 @@ describe("convertClaudeHistoryEntry", () => {
expect(result).toEqual([]);
});
+
+ test("passes thinking blocks to mapBlocks for assistant entries", () => {
+ const entry = {
+ type: "assistant",
+ message: {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "Let me reason about this..." },
+ { type: "text", text: "Here is my answer." },
+ ],
+ },
+ };
+
+ const mapBlocks = vi.fn().mockReturnValue([
+ { type: "reasoning", text: "Let me reason about this..." },
+ { type: "assistant_message", text: "Here is my answer." },
+ ]);
+ const result = convertClaudeHistoryEntry(entry, mapBlocks);
+
+ expect(mapBlocks).toHaveBeenCalledTimes(1);
+ const arg = mapBlocks.mock.calls[0][0];
+ expect(arg).toEqual([
+ { type: "thinking", thinking: "Let me reason about this..." },
+ { type: "text", text: "Here is my answer." },
+ ]);
+ expect(result).toEqual([
+ { type: "reasoning", text: "Let me reason about this..." },
+ { type: "assistant_message", text: "Here is my answer." },
+ ]);
+ });
});
type StreamHydrationUpdate = {
diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts
index ac6f62eda..1638d55a4 100644
--- a/packages/server/src/server/agent/providers/claude-agent.ts
+++ b/packages/server/src/server/agent/providers/claude-agent.ts
@@ -31,6 +31,7 @@ import type {
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
+ AgentPermissionRequestKind,
AgentPermissionResponse,
AgentPermissionUpdate,
AgentPersistenceHandle,
@@ -328,6 +329,17 @@ function isPermissionUpdate(value: AgentPermissionUpdate): value is PermissionUp
return Array.isArray(rules) && typeof behavior === "string" && typeof destination === "string";
}
+function resolvePermissionKind(
+ toolName: string,
+ input: Record
+): AgentPermissionRequestKind {
+ if (toolName === "ExitPlanMode") return "plan";
+ if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) {
+ return "question";
+ }
+ return "tool";
+}
+
export class ClaudeAgentClient implements AgentClient {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -483,6 +495,7 @@ class ClaudeAgentSession implements AgentSession {
private lastOptionsModel: string | null = null;
private activeSidechains = new Map();
private compacting = false;
+ private queryRestartNeeded = false;
constructor(
config: ClaudeAgentConfig,
@@ -722,37 +735,16 @@ class ClaudeAgentSession implements AgentSession {
? thinkingOptionId
: null;
- const query = await this.ensureQuery();
-
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
- // Claude Code TUI exposes only ON/OFF. Default to OFF.
- try {
- await query.setMaxThinkingTokens(0);
- } catch {
- await query.setMaxThinkingTokens(1);
- }
this.config.thinkingOptionId = undefined;
- return;
- }
-
- if (normalizedThinkingOptionId === "on") {
- await query.setMaxThinkingTokens(null);
- this.config.thinkingOptionId = normalizedThinkingOptionId;
- return;
- }
-
- if (normalizedThinkingOptionId === "off") {
- try {
- await query.setMaxThinkingTokens(0);
- } catch {
- // Some runtimes may reject 0; use a tiny cap as "off".
- await query.setMaxThinkingTokens(1);
- }
+ } else if (normalizedThinkingOptionId === "on") {
+ this.config.thinkingOptionId = "on";
+ } else if (normalizedThinkingOptionId === "off") {
this.config.thinkingOptionId = "off";
- return;
+ } else {
+ throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
-
- throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
+ this.queryRestartNeeded = true;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -874,10 +866,18 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(): Promise {
- if (this.query) {
+ if (this.query && !this.queryRestartNeeded) {
return this.query;
}
+ if (this.queryRestartNeeded && this.query) {
+ this.input?.end();
+ try { await this.query.return?.(); } catch { /* ignore */ }
+ this.query = null;
+ this.input = null;
+ this.queryRestartNeeded = false;
+ }
+
const input = new Pushable();
const options = this.buildOptions();
this.logger.debug({ options }, "claude query");
@@ -894,11 +894,10 @@ class ClaudeAgentSession implements AgentSession {
? configuredThinkingOptionId
: "off";
let maxThinkingTokens: number | undefined;
- if (typeof thinkingOptionId === "string" && thinkingOptionId.length > 0) {
- if (thinkingOptionId === "off") {
- maxThinkingTokens = 0;
- }
- // For "on" we omit maxThinkingTokens (SDK default max).
+ if (thinkingOptionId === "on") {
+ maxThinkingTokens = 10000;
+ } else if (thinkingOptionId === "off") {
+ maxThinkingTokens = 0;
}
const appendedSystemPrompt = [
@@ -1117,6 +1116,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.query = null;
this.input = null;
+ this.queryRestartNeeded = false;
} catch (error) {
this.logger.warn({ err: error }, "Failed to interrupt active turn");
}
@@ -1361,7 +1361,7 @@ class ClaudeAgentSession implements AgentSession {
id: requestId,
provider: "claude",
name: toolName,
- kind: toolName === "ExitPlanMode" ? "plan" : "tool",
+ kind: resolvePermissionKind(toolName, input),
input,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index 02394498d..92e91747b 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -130,7 +130,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType