diff --git a/package-lock.json b/package-lock.json index d13fd7c..f9c896b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,9 @@ "": { "name": "@siteboon/claude-code-ui", "version": "1.17.1", - "license": "MIT", + "license": "GPL-3.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.29", + "@anthropic-ai/claude-agent-sdk": "^0.1.71", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.4", @@ -111,9 +111,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.29", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.29.tgz", - "integrity": "sha512-VbR2ybPdJHVKAD3pQdruVw8LdXoPbk5J59xU/bQoMNzAsBckHrD2LhupMJrBxLUWxLaPkIUlNKquGBRbkoK84Q==", + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.71.tgz", + "integrity": "sha512-O34SQCEsdU11Z2uy30GaJGRLdRbEwEvaQs8APywHVOW/EdIGE0rS/AE4V6p9j45/j5AFwh2USZWlbz5NTlLnrw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" @@ -124,10 +124,88 @@ "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.24.1 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@babel/code-frame": { @@ -12508,9 +12586,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index 5e783ea..ec6925b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "author": "Claude Code UI Contributors", "license": "GPL-3.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.29", + "@anthropic-ai/claude-agent-sdk": "^0.1.71", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.4", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 0f7c39a..d5e4cc8 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -13,41 +13,26 @@ */ import { query } from '@anthropic-ai/claude-agent-sdk'; -// Used to mint unique approval request IDs when randomUUID is not available. -// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees. import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; -// Session tracking: Map of session IDs to active query instances const activeSessions = new Map(); -// In-memory registry of pending tool approvals keyed by requestId. -// This does not persist approvals or share across processes; it exists so the -// SDK can pause tool execution while the UI decides what to do. const pendingToolApprovals = new Map(); -// Default approval timeout kept under the SDK's 60s control timeout. -// This does not change SDK limits; it only defines how long we wait for the UI, -// introduced to avoid hanging the run when no decision arrives. const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; -// Generate a stable request ID for UI approval flows. -// This does not encode tool details or get shown to users; it exists so the UI -// can respond to the correct pending request without collisions. +const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']); + function createRequestId() { - // if clause is used because randomUUID is not available in older Node.js versions if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return crypto.randomBytes(16).toString('hex'); } -// Wait for a UI approval decision, honoring SDK cancellation. -// This does not auto-approve or auto-deny; it only resolves with UI input, -// and it cleans up the pending map to avoid leaks, introduced to prevent -// replying after the SDK cancels the control request. function waitForToolApproval(requestId, options = {}) { const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options; @@ -61,24 +46,25 @@ function waitForToolApproval(requestId, options = {}) { resolve(decision); }; + let timeout; + const cleanup = () => { pendingToolApprovals.delete(requestId); - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); if (signal && abortHandler) { signal.removeEventListener('abort', abortHandler); } }; - // Timeout is local to this process; it does not override SDK timing. - // It exists to prevent the UI prompt from lingering indefinitely. - const timeout = setTimeout(() => { - onCancel?.('timeout'); - finalize(null); - }, timeoutMs); + // timeoutMs 0 = wait indefinitely (interactive tools) + if (timeoutMs > 0) { + timeout = setTimeout(() => { + onCancel?.('timeout'); + finalize(null); + }, timeoutMs); + } const abortHandler = () => { - // If the SDK cancels the control request, stop waiting to avoid - // replying after the process is no longer ready for writes. onCancel?.('cancelled'); finalize({ cancelled: true }); }; @@ -98,9 +84,6 @@ function waitForToolApproval(requestId, options = {}) { }); } -// Resolve a pending approval. This does not validate the decision payload; -// validation and tool matching remain in canUseTool, which keeps this as a -// lightweight WebSocket -> SDK relay. function resolveToolApproval(requestId, decision) { const resolver = pendingToolApprovals.get(requestId); if (resolver) { @@ -175,9 +158,6 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.permissionMode = 'bypassPermissions'; } - // Map allowed tools (always set to avoid implicit "allow all" defaults). - // This does not grant permissions by itself; it just configures the SDK, - // introduced because leaving it undefined made the SDK treat it as "all tools allowed." let allowedTools = [...(settings.allowedTools || [])]; // Add plan mode default tools @@ -192,8 +172,11 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.allowedTools = allowedTools; - // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive). - // This does not override allowlists; it only feeds the canUseTool gate. + // Use the tools preset to make all default built-in tools available (including AskUserQuestion). + // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available), + // but being explicit ensures forward compatibility and clarity. + sdkOptions.tools = { type: 'preset', preset: 'claude_code' }; + sdkOptions.disallowedTools = settings.disallowedTools || []; // Map model (default to sonnet) @@ -267,9 +250,7 @@ function getAllSessions() { * @returns {Object} Transformed message ready for WebSocket */ function transformMessage(sdkMessage) { - // SDK messages are already in a format compatible with the frontend - // The CLI sends them wrapped in {type: 'claude-response', data: message} - // We'll do the same here to maintain compatibility + // Pass-through; SDK messages match frontend format. return sdkMessage; } @@ -490,27 +471,27 @@ async function queryClaudeSDK(command, options = {}, ws) { tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; - // Gate tool usage with explicit UI approval when not auto-approved. - // This does not render UI or persist permissions; it only bridges to the UI - // via WebSocket and waits for the response, introduced so tool calls pause - // instead of auto-running when the allowlist is empty. sdkOptions.canUseTool = async (toolName, input, context) => { - if (sdkOptions.permissionMode === 'bypassPermissions') { - return { behavior: 'allow', updatedInput: input }; - } + const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); - const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => - matchesToolPermission(entry, toolName, input) - ); - if (isDisallowed) { - return { behavior: 'deny', message: 'Tool disallowed by settings' }; - } + if (!requiresInteraction) { + if (sdkOptions.permissionMode === 'bypassPermissions') { + return { behavior: 'allow', updatedInput: input }; + } - const isAllowed = (sdkOptions.allowedTools || []).some(entry => - matchesToolPermission(entry, toolName, input) - ); - if (isAllowed) { - return { behavior: 'allow', updatedInput: input }; + const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => + matchesToolPermission(entry, toolName, input) + ); + if (isDisallowed) { + return { behavior: 'deny', message: 'Tool disallowed by settings' }; + } + + const isAllowed = (sdkOptions.allowedTools || []).some(entry => + matchesToolPermission(entry, toolName, input) + ); + if (isAllowed) { + return { behavior: 'allow', updatedInput: input }; + } } const requestId = createRequestId(); @@ -522,9 +503,8 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId || sessionId || null }); - // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner. - // This does not retry or resurface the prompt; it just reflects the cancellation. const decision = await waitForToolApproval(requestId, { + timeoutMs: requiresInteraction ? 0 : undefined, signal: context?.signal, onCancel: (reason) => { ws.send({ @@ -544,8 +524,6 @@ async function queryClaudeSDK(command, options = {}, ws) { } if (decision.allow) { - // rememberEntry only updates this run's in-memory allowlist to prevent - // repeated prompts in the same session; persistence is handled by the UI. if (decision.rememberEntry && typeof decision.rememberEntry === 'string') { if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) { sdkOptions.allowedTools.push(decision.rememberEntry); @@ -560,12 +538,22 @@ async function queryClaudeSDK(command, options = {}, ws) { return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; }; - // Create SDK query instance + // Set stream-close timeout for interactive tools (Query constructor reads it synchronously) + const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; + const queryInstance = query({ prompt: finalCommand, options: sdkOptions }); + // Restore immediately — Query constructor already captured the value + if (prevStreamTimeout !== undefined) { + process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout; + } else { + delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; + } + // Track the query instance for abort capability if (capturedSessionId) { addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 029957d..0d4fa07 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -1,6 +1,6 @@ import React, { memo, useMemo, useCallback } from 'react'; import { getToolConfig } from './configs/toolConfigs'; -import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components'; +import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components'; import type { Project } from '../../../types/app'; type DiffLine = { @@ -31,6 +31,7 @@ function getToolCategory(toolName: string): string { if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task'; if (toolName === 'Task') return 'agent'; // Subagent task if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan'; + if (toolName === 'AskUserQuestion') return 'question'; return 'default'; } @@ -156,6 +157,15 @@ export const ToolRenderer: React.FC = memo(({ contentComponent = ; break; + case 'question-answer': + contentComponent = ( + + ); + break; + case 'text': contentComponent = ( = { task: 'border-l-violet-500 dark:border-l-violet-400', agent: 'border-l-purple-500 dark:border-l-purple-400', plan: 'border-l-indigo-500 dark:border-l-indigo-400', + question: 'border-l-blue-500 dark:border-l-blue-400', default: 'border-l-gray-300 dark:border-l-gray-600', }; diff --git a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx new file mode 100644 index 0000000..9ff2c2a --- /dev/null +++ b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx @@ -0,0 +1,187 @@ +import React, { useState } from 'react'; +import type { Question } from '../../../types/types'; + +interface QuestionAnswerContentProps { + questions: Question[]; + answers: Record; + className?: string; +} + +// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state. +export const QuestionAnswerContent: React.FC = ({ + questions, + answers, + className = '', +}) => { + const [expandedIdx, setExpandedIdx] = useState(null); + + if (!questions || questions.length === 0) { + return null; + } + + const hasAnyAnswer = Object.keys(answers || {}).length > 0; + const total = questions.length; + + return ( +
+ {questions.map((q, idx) => { + const answer = answers?.[q.question]; + const answerLabels = answer ? answer.split(', ') : []; + const skipped = !answer; + const isExpanded = expandedIdx === idx; + + return ( +
+ + + {isExpanded && ( +
+
+ {q.options.map((opt) => { + const wasSelected = answerLabels.includes(opt.label); + return ( +
+
+ {wasSelected && ( + + + + )} +
+
+ + {opt.label} + + {opt.description && ( + + {opt.description} + + )} +
+
+ ); + })} + + {answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => ( +
+
+ + + +
+
+ {lbl} + (custom) +
+
+ ))} + + {skipped && hasAnyAnswer && ( +
+ No answer provided +
+ )} +
+
+ )} +
+ ); + })} + + {!hasAnyAnswer && total === 1 && ( +
+ Skipped +
+ )} +
+ ); +}; diff --git a/src/components/chat/tools/components/ContentRenderers/index.ts b/src/components/chat/tools/components/ContentRenderers/index.ts index 86d6be3..6cac32e 100644 --- a/src/components/chat/tools/components/ContentRenderers/index.ts +++ b/src/components/chat/tools/components/ContentRenderers/index.ts @@ -3,3 +3,4 @@ export { FileListContent } from './FileListContent'; export { TodoListContent } from './TodoListContent'; export { TaskListContent } from './TaskListContent'; export { TextContent } from './TextContent'; +export { QuestionAnswerContent } from './QuestionAnswerContent'; diff --git a/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx b/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx new file mode 100644 index 0000000..7186851 --- /dev/null +++ b/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx @@ -0,0 +1,384 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import type { PermissionPanelProps } from '../../configs/permissionPanelRegistry'; +import type { Question } from '../../../types/types'; + +export const AskUserQuestionPanel: React.FC = ({ + request, + onDecision, +}) => { + const input = request.input as { questions?: Question[] } | undefined; + const questions: Question[] = input?.questions || []; + + const [currentStep, setCurrentStep] = useState(0); + const [selections, setSelections] = useState>>(() => new Map()); + const [otherTexts, setOtherTexts] = useState>(() => new Map()); + const [otherActive, setOtherActive] = useState>(() => new Map()); + const [mounted, setMounted] = useState(false); + + const containerRef = useRef(null); + const otherInputRef = useRef(null); + + useEffect(() => { + requestAnimationFrame(() => setMounted(true)); + }, []); + + // Focus the container for keyboard events when step changes + useEffect(() => { + if (!otherActive.get(currentStep)) { + containerRef.current?.focus(); + } + }, [currentStep, otherActive]); + + useEffect(() => { + if (otherActive.get(currentStep)) { + otherInputRef.current?.focus(); + } + }, [otherActive, currentStep]); + + const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => { + setSelections(prev => { + const next = new Map(prev); + const current = new Set(next.get(qIdx) || []); + if (multiSelect) { + if (current.has(label)) current.delete(label); + else current.add(label); + } else { + current.clear(); + current.add(label); + setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; }); + } + next.set(qIdx, current); + return next; + }); + }, []); + + const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => { + setOtherActive(prev => { + const next = new Map(prev); + const wasActive = next.get(qIdx) || false; + next.set(qIdx, !wasActive); + if (!multiSelect && !wasActive) { + setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; }); + } + return next; + }); + }, []); + + const setOtherText = useCallback((qIdx: number, text: string) => { + setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; }); + }, []); + + const buildAnswers = useCallback(() => { + const answers: Record = {}; + questions.forEach((q, idx) => { + const selected = Array.from(selections.get(idx) || []); + const isOther = otherActive.get(idx) || false; + const otherText = (otherTexts.get(idx) || '').trim(); + if (isOther && otherText) selected.push(otherText); + if (selected.length > 0) answers[q.question] = selected.join(', '); + }); + return answers; + }, [questions, selections, otherActive, otherTexts]); + + const handleSubmit = useCallback(() => { + onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } }); + }, [onDecision, request.requestId, input, buildAnswers]); + + const handleSkip = useCallback(() => { + onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } }); + }, [onDecision, request.requestId, input]); + + // Keyboard handler for number keys and navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + // Don't capture keys when typing in the "Other" input + if (e.target instanceof HTMLInputElement) return; + + const q = questions[currentStep]; + if (!q) return; + const multi = q.multiSelect || false; + const optCount = q.options.length; + + // Number keys 1-9 for options + const num = parseInt(e.key); + if (!isNaN(num) && num >= 1 && num <= optCount) { + e.preventDefault(); + toggleOption(currentStep, q.options[num - 1].label, multi); + return; + } + + // 0 for "Other" + if (e.key === '0') { + e.preventDefault(); + toggleOther(currentStep, multi); + return; + } + + // Enter to advance / submit + if (e.key === 'Enter') { + e.preventDefault(); + const isLast = currentStep === questions.length - 1; + if (isLast) handleSubmit(); + else setCurrentStep(s => s + 1); + return; + } + + // Escape to skip + if (e.key === 'Escape') { + e.preventDefault(); + handleSkip(); + return; + } + }, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]); + + if (questions.length === 0) return null; + + const total = questions.length; + const isSingle = total === 1; + const q = questions[currentStep]; + const multi = q.multiSelect || false; + const selected = selections.get(currentStep) || new Set(); + const isOtherOn = otherActive.get(currentStep) || false; + const isLast = currentStep === total - 1; + const isFirst = currentStep === 0; + const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0); + + return ( +
+
+ {/* Accent line */} +
+ + {/* Header + Question — compact */} +
+
+ {/* Question icon */} +
+
+ + + +
+
+
+ +
+ + Claude needs your input + + {q.header && ( + + {q.header} + + )} +
+ + {/* Step counter */} + {!isSingle && ( + + {currentStep + 1}/{total} + + )} +
+ + {/* Progress dots (multi-question) */} + {!isSingle && ( +
+ {questions.map((_, i) => ( +
+ )} + + {/* Question text */} +

+ {q.question} +

+ {multi && ( + Select all that apply + )} +
+ + {/* Options — tight spacing */} +
+
+ {q.options.map((opt, optIdx) => { + const isSelected = selected.has(opt.label); + return ( + + ); + })} + + {/* "Other" option */} + + + {/* Other text input — inline */} + {isOtherOn && ( +
+
+ setOtherText(currentStep, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isLast) handleSubmit(); + else setCurrentStep(s => s + 1); + } + // Prevent container keydown from firing + e.stopPropagation(); + }} + placeholder="Type your answer..." + className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200" + /> + + Enter + +
+
+ )} +
+
+ + {/* Footer — compact */} +
+ + +
+ {!isSingle && !isFirst && ( + + )} + + {isLast ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/src/components/chat/tools/components/InteractiveRenderers/index.ts b/src/components/chat/tools/components/InteractiveRenderers/index.ts new file mode 100644 index 0000000..3bb9ae9 --- /dev/null +++ b/src/components/chat/tools/components/InteractiveRenderers/index.ts @@ -0,0 +1 @@ +export { AskUserQuestionPanel } from './AskUserQuestionPanel'; diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts index fce0c5e..71cdb52 100644 --- a/src/components/chat/tools/components/index.ts +++ b/src/components/chat/tools/components/index.ts @@ -3,3 +3,4 @@ export { DiffViewer } from './DiffViewer'; export { OneLineDisplay } from './OneLineDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay'; export * from './ContentRenderers'; +export * from './InteractiveRenderers'; diff --git a/src/components/chat/tools/configs/permissionPanelRegistry.ts b/src/components/chat/tools/configs/permissionPanelRegistry.ts new file mode 100644 index 0000000..a9160fb --- /dev/null +++ b/src/components/chat/tools/configs/permissionPanelRegistry.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from 'react'; +import type { PendingPermissionRequest } from '../../types/types'; + +export interface PermissionPanelProps { + request: PendingPermissionRequest; + onDecision: ( + requestIds: string | string[], + decision: { allow?: boolean; message?: string; updatedInput?: unknown }, + ) => void; +} + +const registry: Record> = {}; + +export function registerPermissionPanel( + toolName: string, + component: ComponentType, +): void { + registry[toolName] = component; +} + +export function getPermissionPanel( + toolName: string, +): ComponentType | null { + return registry[toolName] || null; +} diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts index 3bb4219..8fe1608 100644 --- a/src/components/chat/tools/configs/toolConfigs.ts +++ b/src/components/chat/tools/configs/toolConfigs.ts @@ -24,7 +24,7 @@ export interface ToolDisplayConfig { // Collapsible config title?: string | ((input: any) => string); defaultOpen?: boolean; - contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task'; + contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer'; getContentProps?: (input: any, helpers?: any) => any; actionButton?: 'file-button' | 'none'; }; @@ -35,7 +35,7 @@ export interface ToolDisplayConfig { title?: string | ((result: any) => string); defaultOpen?: boolean; // Special result handlers - contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task'; + contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer'; getMessage?: (result: any) => string; getContentProps?: (result: any) => any; }; @@ -453,6 +453,34 @@ export const TOOL_CONFIGS: Record = { } }, + // ============================================================================ + // INTERACTIVE TOOLS + // ============================================================================ + + AskUserQuestion: { + input: { + type: 'collapsible', + title: (input: any) => { + const count = input.questions?.length || 0; + const hasAnswers = input.answers && Object.keys(input.answers).length > 0; + if (count === 1) { + const header = input.questions[0]?.header || 'Question'; + return hasAnswers ? `${header} — answered` : header; + } + return hasAnswers ? `${count} questions — answered` : `${count} questions`; + }, + defaultOpen: true, + contentType: 'question-answer', + getContentProps: (input: any) => ({ + questions: input.questions || [], + answers: input.answers || {} + }), + }, + result: { + hideOnSuccess: true + } + }, + // ============================================================================ // PLAN TOOLS // ============================================================================ diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index de166e0..825b8a5 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -65,6 +65,18 @@ export interface PendingPermissionRequest { receivedAt?: Date; } +export interface QuestionOption { + label: string; + description?: string; +} + +export interface Question { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index cd6cd1f..acc65f7 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -402,11 +402,26 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { content.startsWith('[Request interrupted'); if (!shouldSkip) { - converted.push({ - type: 'user', - content: unescapeWithMathProtection(content), - timestamp: message.timestamp || new Date().toISOString(), - }); + // Parse blocks into compact system messages + const taskNotifRegex = /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/g; + const taskNotifMatch = taskNotifRegex.exec(content); + if (taskNotifMatch) { + const status = taskNotifMatch[1]?.trim() || 'completed'; + const summary = taskNotifMatch[2]?.trim() || 'Background task finished'; + converted.push({ + type: 'assistant', + content: summary, + timestamp: message.timestamp || new Date().toISOString(), + isTaskNotification: true, + taskStatus: status, + }); + } else { + converted.push({ + type: 'user', + content: unescapeWithMathProtection(content), + timestamp: message.timestamp || new Date().toISOString(), + }); + } } return; } diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 13639bd..fafc2c7 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -157,16 +157,23 @@ export default function ChatComposer({ bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, }; + // Detect if the AskUserQuestion interactive panel is active + const hasQuestionPanel = pendingPermissionRequests.some( + (r) => r.toolName === 'AskUserQuestion' + ); + return (
-
- -
+ {!hasQuestionPanel && ( +
+ +
+ )}
- + />}
-
) => void} className="relative max-w-4xl mx-auto"> + {!hasQuestionPanel && ) => void} className="relative max-w-4xl mx-auto"> {isDragActive && (
@@ -340,7 +347,7 @@ export default function ChatComposer({
- + }
); } diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index a90332b..c76ae7e 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -127,6 +127,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} + ) : message.isTaskNotification ? ( + /* Compact task notification on the left */ +
+
+ + {message.content} +
+
) : ( /* Claude/Error/Tool messages on the left */
diff --git a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx index cfe86d3..98aced0 100644 --- a/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx +++ b/src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx @@ -2,6 +2,10 @@ import React from 'react'; import type { PendingPermissionRequest } from '../../types/types'; import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions'; import { getClaudeSettings } from '../../utils/chatStorage'; +import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry'; +import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers'; + +registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel); interface PermissionRequestsBannerProps { pendingPermissionRequests: PendingPermissionRequest[]; @@ -24,6 +28,17 @@ export default function PermissionRequestsBanner({ return (
{pendingPermissionRequests.map((request) => { + const CustomPanel = getPermissionPanel(request.toolName); + if (CustomPanel) { + return ( + + ); + } + const rawInput = formatToolInputForDisplay(request.input); const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput); const settings = getClaudeSettings();