import { useState, useCallback, useRef, useEffect } from "react";
import {
chatWithFile,
type BodyElement,
type LlmModel,
type ChatMessage,
type UserQuestion,
type UserAnswer,
} from "../../lib/api";
import { SimpleMarkdown } from "../SimpleMarkdown";
import type { FocusedElement } from "./FileDetail";
interface CliInputProps {
fileId: string;
onUpdate: (body: BodyElement[], summary: string | null) => void;
focusedElement?: FocusedElement | null;
onClearFocus?: () => void;
suggestedPrompt?: string | null;
onClearSuggestedPrompt?: () => void;
}
interface Message {
id: string;
type: "user" | "assistant" | "error" | "question";
content: string;
toolCalls?: { name: string; success: boolean; message: string }[];
questions?: UserQuestion[];
}
const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
{ value: "claude-opus", label: "Claude Opus" },
{ value: "claude-sonnet", label: "Claude Sonnet" },
{ value: "groq", label: "Groq Kimi" },
];
export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [expanded, setExpanded] = useState(false);
const [model, setModel] = useState<LlmModel>("claude-opus");
// Track conversation history for context continuity
const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([]);
// Track pending questions from the LLM
const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null);
// Track user's answers to questions
const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map());
// Track custom input for each question
const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map());
const inputRef = useRef<HTMLInputElement>(null);
const messagesRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, [messages]);
// Auto-focus input when an element is focused
useEffect(() => {
if (focusedElement && inputRef.current) {
inputRef.current.focus();
}
}, [focusedElement]);
// Handle suggested prompt from generate actions
useEffect(() => {
if (suggestedPrompt) {
setInput(suggestedPrompt);
onClearSuggestedPrompt?.();
}
}, [suggestedPrompt, onClearSuggestedPrompt]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
const userMessage = input.trim();
setInput("");
setExpanded(true);
// Add user message
const userMsgId = Date.now().toString();
setMessages((prev) => [
...prev,
{ id: userMsgId, type: "user", content: userMessage },
]);
setLoading(true);
try {
// Send request with conversation history for context
const response = await chatWithFile(
fileId,
userMessage,
model,
conversationHistory,
focusedElement?.index
);
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
type: response.pendingQuestions?.length ? "question" : "assistant",
content: response.response,
toolCalls: response.toolCalls.map((tc) => ({
name: tc.name,
success: tc.result.success,
message: tc.result.message,
})),
questions: response.pendingQuestions,
},
]);
// Update conversation history for next request
setConversationHistory((prev) => [
...prev,
{ role: "user", content: userMessage },
{ role: "assistant", content: response.response },
]);
// Handle pending questions
if (response.pendingQuestions?.length) {
setPendingQuestions(response.pendingQuestions);
// Initialize answers map
const initialAnswers = new Map<string, string[]>();
response.pendingQuestions.forEach((q) => {
initialAnswers.set(q.id, []);
});
setUserAnswers(initialAnswers);
setCustomInputs(new Map());
}
// Update parent with new body/summary
onUpdate(response.updatedBody, response.updatedSummary);
} catch (err) {
const errorMsgId = (Date.now() + 1).toString();
setMessages((prev) => [
...prev,
{
id: errorMsgId,
type: "error",
content: err instanceof Error ? err.message : "An error occurred",
},
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
},
[input, loading, fileId, model, onUpdate, conversationHistory, focusedElement]
);
// Handle option selection for a question
const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => {
setUserAnswers((prev) => {
const newMap = new Map(prev);
const currentAnswers = newMap.get(questionId) || [];
if (allowMultiple) {
// Toggle option in array
if (currentAnswers.includes(option)) {
newMap.set(questionId, currentAnswers.filter((a) => a !== option));
} else {
newMap.set(questionId, [...currentAnswers, option]);
}
} else {
// Single select - replace
newMap.set(questionId, [option]);
}
return newMap;
});
}, []);
// Handle custom input change
const handleCustomInputChange = useCallback((questionId: string, value: string) => {
setCustomInputs((prev) => {
const newMap = new Map(prev);
newMap.set(questionId, value);
return newMap;
});
}, []);
// Submit answers to questions
const handleSubmitAnswers = useCallback(async () => {
if (!pendingQuestions || loading) return;
// Build answers array, including custom inputs
const answers: UserAnswer[] = pendingQuestions.map((q) => {
const selectedOptions = userAnswers.get(q.id) || [];
const customInput = customInputs.get(q.id)?.trim();
// If there's a custom input, add it to answers
const finalAnswers = customInput
? [...selectedOptions, customInput]
: selectedOptions;
return {
id: q.id,
answers: finalAnswers,
};
});
// Format answers as a message
const answerText = answers
.map((a) => {
const question = pendingQuestions.find((q) => q.id === a.id);
return `${question?.question || a.id}: ${a.answers.join(", ")}`;
})
.join("\n");
// Clear pending questions
setPendingQuestions(null);
setUserAnswers(new Map());
setCustomInputs(new Map());
// Add user answer message
const userMsgId = Date.now().toString();
setMessages((prev) => [
...prev,
{ id: userMsgId, type: "user", content: `[Answers]\n${answerText}` },
]);
setLoading(true);
try {
// Send answers as the next message
const response = await chatWithFile(
fileId,
answerText,
model,
conversationHistory,
focusedElement?.index
);
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
type: response.pendingQuestions?.length ? "question" : "assistant",
content: response.response,
toolCalls: response.toolCalls.map((tc) => ({
name: tc.name,
success: tc.result.success,
message: tc.result.message,
})),
questions: response.pendingQuestions,
},
]);
// Update conversation history
setConversationHistory((prev) => [
...prev,
{ role: "user", content: answerText },
{ role: "assistant", content: response.response },
]);
// Handle more pending questions
if (response.pendingQuestions?.length) {
setPendingQuestions(response.pendingQuestions);
const initialAnswers = new Map<string, string[]>();
response.pendingQuestions.forEach((q) => {
initialAnswers.set(q.id, []);
});
setUserAnswers(initialAnswers);
setCustomInputs(new Map());
}
// Update parent with new body/summary
onUpdate(response.updatedBody, response.updatedSummary);
} catch (err) {
const errorMsgId = (Date.now() + 1).toString();
setMessages((prev) => [
...prev,
{
id: errorMsgId,
type: "error",
content: err instanceof Error ? err.message : "An error occurred",
},
]);
} finally {
setLoading(false);
}
}, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]);
// Cancel answering questions
const handleCancelQuestions = useCallback(() => {
setPendingQuestions(null);
setUserAnswers(new Map());
setCustomInputs(new Map());
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
setConversationHistory([]);
setPendingQuestions(null);
setUserAnswers(new Map());
setCustomInputs(new Map());
}, []);
return (
<div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
{/* Messages Panel (expandable) */}
{expanded && messages.length > 0 && (
<div
ref={messagesRef}
className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
>
{messages.map((msg) => (
<div key={msg.id} className="font-mono text-xs">
{msg.type === "user" && (
<div className="flex gap-2">
<span className="text-[#9bc3ff]">></span>
<span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
</div>
)}
{(msg.type === "assistant" || msg.type === "question") && (
<div className="pl-4 space-y-1">
<SimpleMarkdown content={msg.content} className="text-[#75aafc]" />
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="text-[#555] text-[10px] space-y-0.5">
{msg.toolCalls.map((tc, i) => (
<div key={i}>
<span
className={
tc.success ? "text-green-500" : "text-red-400"
}
>
{tc.success ? "+" : "x"}
</span>{" "}
{tc.name}: {tc.message}
</div>
))}
</div>
)}
</div>
)}
{msg.type === "error" && (
<div className="pl-4 text-red-400">{msg.content}</div>
)}
</div>
))}
</div>
)}
{/* Pending Questions UI */}
{pendingQuestions && pendingQuestions.length > 0 && (
<div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
<div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
Questions from AI
</div>
{pendingQuestions.map((q) => (
<div key={q.id} className="space-y-2">
<div className="text-white/90 font-mono text-sm">{q.question}</div>
<div className="flex flex-wrap gap-2">
{q.options.map((option) => {
const isSelected = (userAnswers.get(q.id) || []).includes(option);
return (
<button
key={option}
type="button"
onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)}
className={`px-2 py-1 font-mono text-xs border transition-colors ${
isSelected
? "bg-[#3f6fb3] border-[#75aafc] text-white"
: "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
}`}
>
{q.allowMultiple && (
<span className="mr-1">{isSelected ? "☑" : "☐"}</span>
)}
{option}
</button>
);
})}
</div>
{q.allowCustom && (
<input
type="text"
value={customInputs.get(q.id) || ""}
onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
placeholder="Or type a custom answer..."
className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
/>
)}
</div>
))}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleSubmitAnswers}
disabled={loading}
className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
>
{loading ? "..." : "Submit Answers"}
</button>
<button
type="button"
onClick={handleCancelQuestions}
disabled={loading}
className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Input Bar */}
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
<select
value={model}
onChange={(e) => setModel(e.target.value as LlmModel)}
disabled={loading || !!pendingQuestions}
className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50"
>
{MODEL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Focus Badge */}
{focusedElement && (
<button
type="button"
onClick={onClearFocus}
className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group"
title="Click to clear focus"
>
<span className="text-[#75aafc]">{focusedElement.type}</span>
<span className="text-[#555]">:</span>
<span>{focusedElement.index}</span>
<span className="text-[#555] group-hover:text-red-400 ml-1">×</span>
</button>
)}
<span className="text-[#9bc3ff] font-mono text-sm">></span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
loading
? "Processing..."
: pendingQuestions
? "Answer questions above first..."
: "Add a heading, chart, or summary..."
}
disabled={loading || !!pendingQuestions}
className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
/>
{messages.length > 0 && (
<button
type="button"
onClick={clearMessages}
className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
>
clear
</button>
)}
<button
type="submit"
disabled={loading || !input.trim() || !!pendingQuestions}
className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
>
{loading ? "..." : "Send"}
</button>
</form>
</div>
);
}