feature: Ask User Question implementation for Claude Code & upgrade claude agent sdk to 0.1.71 to support the tool

This commit is contained in:
simosmik
2026-02-16 12:14:51 +00:00
parent 272eb00602
commit 42f13e151c
17 changed files with 851 additions and 90 deletions

View File

@@ -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<ToolRendererProps> = memo(({
contentComponent = <TaskListContent content={contentProps.content || ''} />;
break;
case 'question-answer':
contentComponent = (
<QuestionAnswerContent
questions={contentProps.questions || []}
answers={contentProps.answers || {}}
/>
);
break;
case 'text':
contentComponent = (
<TextContent

View File

@@ -23,6 +23,7 @@ const borderColorMap: Record<string, string> = {
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',
};

View File

@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import type { Question } from '../../../types/types';
interface QuestionAnswerContentProps {
questions: Question[];
answers: Record<string, string>;
className?: string;
}
// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.
export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
questions,
answers,
className = '',
}) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
if (!questions || questions.length === 0) {
return null;
}
const hasAnyAnswer = Object.keys(answers || {}).length > 0;
const total = questions.length;
return (
<div className={`space-y-2 ${className}`}>
{questions.map((q, idx) => {
const answer = answers?.[q.question];
const answerLabels = answer ? answer.split(', ') : [];
const skipped = !answer;
const isExpanded = expandedIdx === idx;
return (
<div
key={idx}
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
>
<button
type="button"
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
answerLabels.length > 0
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
{answerLabels.length > 0 ? (
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{q.header && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
{q.header}
</span>
)}
{total > 1 && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
{idx + 1}/{total}
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
{q.question}
</div>
{!isExpanded && answerLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{answerLabels.map((lbl) => {
const isCustom = !q.options.some(o => o.label === lbl);
return (
<span
key={lbl}
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
>
{lbl}
{isCustom && (
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
)}
</span>
);
})}
</div>
)}
{!isExpanded && skipped && hasAnyAnswer && (
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
Skipped
</span>
)}
</div>
<svg
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isExpanded && (
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
<div className="space-y-1 ml-6.5">
{q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
key={opt.label}
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
wasSelected
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
: 'text-gray-400 dark:text-gray-500'
}`}
>
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
wasSelected
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{wasSelected && (
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
{opt.label}
</span>
{opt.description && (
<span className={`block text-[11px] mt-0.5 ${
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
}`}>
{opt.description}
</span>
)}
</div>
</div>
);
})}
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div
key={lbl}
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
>
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
</div>
</div>
))}
{skipped && hasAnyAnswer && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
No answer provided
</div>
)}
</div>
</div>
)}
</div>
);
})}
{!hasAnyAnswer && total === 1 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
Skipped
</div>
)}
</div>
);
};

View File

@@ -3,3 +3,4 @@ export { FileListContent } from './FileListContent';
export { TodoListContent } from './TodoListContent';
export { TaskListContent } from './TaskListContent';
export { TextContent } from './TextContent';
export { QuestionAnswerContent } from './QuestionAnswerContent';

View File

@@ -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<PermissionPanelProps> = ({
request,
onDecision,
}) => {
const input = request.input as { questions?: Question[] } | undefined;
const questions: Question[] = input?.questions || [];
const [currentStep, setCurrentStep] = useState(0);
const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());
const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());
const [mounted, setMounted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(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<string, string> = {};
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<string>();
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 (
<div
ref={containerRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={`w-full outline-none transition-all duration-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3'
}`}
>
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl">
{/* Accent line */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
{/* Header + Question — compact */}
<div className="px-4 pt-3.5 pb-2">
<div className="flex items-center gap-2.5 mb-1.5">
{/* Question icon */}
<div className="relative flex-shrink-0">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
</svg>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" />
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500">
Claude needs your input
</span>
{q.header && (
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50">
{q.header}
</span>
)}
</div>
{/* Step counter */}
{!isSingle && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0">
{currentStep + 1}/{total}
</span>
)}
</div>
{/* Progress dots (multi-question) */}
{!isSingle && (
<div className="flex items-center gap-1 mb-2">
{questions.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setCurrentStep(i)}
className={`h-[3px] rounded-full transition-all duration-300 ${
i === currentStep
? 'w-5 bg-blue-500 dark:bg-blue-400'
: i < currentStep
? 'w-2.5 bg-blue-300 dark:bg-blue-600'
: 'w-2.5 bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
)}
{/* Question text */}
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100">
{q.question}
</p>
{multi && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">Select all that apply</span>
)}
</div>
{/* Options — tight spacing */}
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
<div className="space-y-1">
{q.options.map((opt, optIdx) => {
const isSelected = selected.has(opt.label);
return (
<button
key={opt.label}
type="button"
onClick={() => toggleOption(currentStep, opt.label, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
isSelected
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
}`}
>
{/* Keyboard hint */}
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
isSelected
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
}`}>
{optIdx + 1}
</kbd>
<div className="flex-1 min-w-0">
<div className={`text-[13px] leading-tight transition-colors duration-150 ${
isSelected
? 'text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}>
{opt.label}
</div>
{opt.description && (
<div className={`text-[11px] leading-snug transition-colors duration-150 ${
isSelected
? 'text-blue-600/70 dark:text-blue-300/70'
: 'text-gray-400 dark:text-gray-500'
}`}>
{opt.description}
</div>
)}
</div>
{/* Selection check */}
{isSelected && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
);
})}
{/* "Other" option */}
<button
type="button"
onClick={() => toggleOther(currentStep, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
isOtherOn
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
}`}
>
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
isOtherOn
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
}`}>
0
</kbd>
<span className={`text-[13px] leading-tight transition-colors ${
isOtherOn
? 'text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}>
Other...
</span>
{isOtherOn && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
{/* Other text input — inline */}
{isOtherOn && (
<div className="pl-[30px] pr-0.5">
<div className="relative">
<input
ref={otherInputRef}
type="text"
value={otherTexts.get(currentStep) || ''}
onChange={(e) => 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"
/>
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700">
Enter
</kbd>
</div>
</div>
)}
</div>
</div>
{/* Footer — compact */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2">
<button
type="button"
onClick={handleSkip}
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{isSingle ? 'Skip' : 'Skip all'}
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
</button>
<div className="flex items-center gap-1.5">
{!isSingle && !isFirst && (
<button
type="button"
onClick={() => setCurrentStep(s => s - 1)}
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
)}
{isLast ? (
<button
type="button"
onClick={handleSubmit}
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200"
>
Submit
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
</button>
) : (
<button
type="button"
onClick={() => setCurrentStep(s => s + 1)}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200"
>
Next
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
</button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { AskUserQuestionPanel } from './AskUserQuestionPanel';

View File

@@ -3,3 +3,4 @@ export { DiffViewer } from './DiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export * from './ContentRenderers';
export * from './InteractiveRenderers';

View File

@@ -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<string, ComponentType<PermissionPanelProps>> = {};
export function registerPermissionPanel(
toolName: string,
component: ComponentType<PermissionPanelProps>,
): void {
registry[toolName] = component;
}
export function getPermissionPanel(
toolName: string,
): ComponentType<PermissionPanelProps> | null {
return registry[toolName] || null;
}

View File

@@ -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<string, ToolDisplayConfig> = {
}
},
// ============================================================================
// 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
// ============================================================================

View File

@@ -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;

View File

@@ -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 <task-notification> blocks into compact system messages
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/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;
}

View File

@@ -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 (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
</div>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
</div>
)}
<div className="max-w-4xl mx-auto mb-3">
<PermissionRequestsBanner
@@ -175,7 +182,7 @@ export default function ChatComposer({
handleGrantToolPermission={handleGrantToolPermission}
/>
<ChatInputControls
{!hasQuestionPanel && <ChatInputControls
permissionMode={permissionMode}
onModeSwitch={onModeSwitch}
provider={provider}
@@ -189,10 +196,10 @@ export default function ChatComposer({
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>
/>}
</div>
<form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
{isDragActive && (
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
@@ -340,7 +347,7 @@ export default function ChatComposer({
</div>
</div>
</div>
</form>
</form>}
</div>
);
}

View File

@@ -127,6 +127,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
)}
</div>
) : message.isTaskNotification ? (
/* Compact task notification on the left */
<div className="w-full">
<div className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
</div>
</div>
) : (
/* Claude/Error/Tool messages on the left */
<div className="w-full">

View File

@@ -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 (
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const CustomPanel = getPermissionPanel(request.toolName);
if (CustomPanel) {
return (
<CustomPanel
key={request.requestId}
request={request}
onDecision={handlePermissionDecision}
/>
);
}
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();