From f79c416c58557d2f946aa5332989afdfa8c021cd Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 2 Jan 2026 22:13:28 +0000
Subject: Add defined user input dialogue to LLM edit

---
 makima/frontend/src/components/files/CliInput.tsx | 254 +++++++++++++++++++++-
 1 file changed, 245 insertions(+), 9 deletions(-)

(limited to 'makima/frontend/src/components/files/CliInput.tsx')

diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx
index c1e6b6d..ff2b0a4 100644
--- a/makima/frontend/src/components/files/CliInput.tsx
+++ b/makima/frontend/src/components/files/CliInput.tsx
@@ -1,5 +1,12 @@
 import { useState, useCallback, useRef, useEffect } from "react";
-import { chatWithFile, type BodyElement, type LlmModel, type ChatMessage } from "../../lib/api";
+import {
+  chatWithFile,
+  type BodyElement,
+  type LlmModel,
+  type ChatMessage,
+  type UserQuestion,
+  type UserAnswer,
+} from "../../lib/api";
 import { SimpleMarkdown } from "../SimpleMarkdown";
 
 interface CliInputProps {
@@ -9,9 +16,10 @@ interface CliInputProps {
 
 interface Message {
   id: string;
-  type: "user" | "assistant" | "error";
+  type: "user" | "assistant" | "error" | "question";
   content: string;
   toolCalls?: { name: string; success: boolean; message: string }[];
+  questions?: UserQuestion[];
 }
 
 const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
@@ -28,6 +36,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
   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);
 
@@ -66,13 +81,14 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
           ...prev,
           {
             id: assistantMsgId,
-            type: "assistant",
+            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,
           },
         ]);
 
@@ -83,6 +99,18 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
           { 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) {
@@ -103,9 +131,148 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
     [input, loading, fileId, model, onUpdate, conversationHistory]
   );
 
+  // 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);
+
+      // 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]);
+
+  // 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 (
@@ -121,10 +288,10 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
               {msg.type === "user" && (
                 <div className="flex gap-2">
                   <span className="text-[#9bc3ff]">&gt;</span>
-                  <span className="text-white/80">{msg.content}</span>
+                  <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
                 </div>
               )}
-              {msg.type === "assistant" && (
+              {(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 && (
@@ -153,12 +320,75 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
         </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}
+          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) => (
@@ -173,8 +403,14 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
           type="text"
           value={input}
           onChange={(e) => setInput(e.target.value)}
-          placeholder={loading ? "Processing..." : "Add a heading, chart, or summary..."}
-          disabled={loading}
+          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 && (
@@ -188,7 +424,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
         )}
         <button
           type="submit"
-          disabled={loading || !input.trim()}
+          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"}
-- 
cgit v1.2.3

