import type { MultilineTextEditorHandle } from "./multiline-editor"; import type { ReviewDecision } from "../../utils/agent/review.js"; import type { HistoryEntry } from "../../utils/storage/command-history.js"; import type { ResponseInputItem, ResponseItem, } from "openai/resources/responses/responses.mjs"; import MultilineTextEditor from "./multiline-editor"; import { TerminalChatCommandReview } from "./terminal-chat-command-review.js"; import { loadConfig } from "../../utils/config.js"; import { createInputItem } from "../../utils/input-utils.js"; import { log } from "../../utils/logger/log.js"; import { setSessionId } from "../../utils/session.js"; import { loadCommandHistory, addToHistory, } from "../../utils/storage/command-history.js"; import { clearTerminal, onExit } from "../../utils/terminal.js"; import { Box, Text, useApp, useInput, useStdin } from "ink"; import { fileURLToPath } from "node:url"; import React, { useCallback, useState, Fragment, useEffect } from "react"; import { useInterval } from "use-interval"; const suggestions = [ "explain this codebase to me", "fix any build errors", "are there any bugs in my code?", ]; const typeHelpText = `ctrl+c to exit | "/clear" to reset context | "/help" for commands | ↑↓ to recall history | ctrl+x to open external editor | enter to send`; // Enable verbose logging for the history‑navigation logic when the // DEBUG_TCI environment variable is truthy. The traces help while debugging // unit‑test failures but remain silent in production. const DEBUG_HIST = process.env["DEBUG_TCI"] === "1" || process.env["DEBUG_TCI"] === "true"; // Placeholder for potential dynamic prompts – currently not used. export default function TerminalChatInput({ isNew: _isNew, loading, submitInput, confirmationPrompt, explanation, submitConfirmation, setLastResponseId, setItems, contextLeftPercent, openOverlay, openModelOverlay, openApprovalOverlay, openHelpOverlay, openDiffOverlay, interruptAgent, active, thinkingSeconds, }: { isNew: boolean; loading: boolean; submitInput: (input: Array) => void; confirmationPrompt: React.ReactNode | null; explanation?: string; submitConfirmation: ( decision: ReviewDecision, customDenyMessage?: string, ) => void; setLastResponseId: (lastResponseId: string) => void; setItems: React.Dispatch>>; contextLeftPercent: number; openOverlay: () => void; openModelOverlay: () => void; openApprovalOverlay: () => void; openHelpOverlay: () => void; openDiffOverlay: () => void; interruptAgent: () => void; active: boolean; thinkingSeconds: number; }): React.ReactElement { const app = useApp(); const [selectedSuggestion, setSelectedSuggestion] = useState(0); const [input, setInput] = useState(""); const [history, setHistory] = useState>([]); const [historyIndex, setHistoryIndex] = useState(null); const [draftInput, setDraftInput] = useState(""); // Multiline text editor is now the default input mode. We keep an // incremental `editorKey` so that we can force‑remount the component and // thus reset its internal buffer after each successful submit. const [editorKey, setEditorKey] = useState(0); // Load command history on component mount useEffect(() => { async function loadHistory() { const historyEntries = await loadCommandHistory(); setHistory(historyEntries); } loadHistory(); }, []); // Imperative handle from the multiline editor so we can query caret position const editorRef = React.useRef(null); // Track the caret row across keystrokes so we can tell whether the cursor // was *already* on the first/last line before the current key event. This // lets us distinguish between a normal vertical navigation (e.g. moving // from row 1 → row 0 inside a multi‑line draft) and an attempt to navigate // the chat history (pressing ↑ again while already at row 0). const prevCursorRow = React.useRef(null); useInput( (_input, _key) => { if (!confirmationPrompt && !loading) { if (_key.upArrow) { if (DEBUG_HIST) { // eslint-disable-next-line no-console console.log("[TCI] upArrow", { historyIndex, input, cursorRow: editorRef.current?.getRow?.(), }); } // Only recall history when the caret was *already* on the very first // row *before* this key‑press. That means the user pressed ↑ while // the cursor sat at the top – mirroring how shells like Bash/zsh // enter history navigation. When the caret starts on a lower line // the first ↑ should merely move it up one row; only a subsequent // press (when we are *still* at row 0) should trigger the recall. const cursorRow = editorRef.current?.getRow?.() ?? 0; const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0; if (history.length > 0 && cursorRow === 0 && wasAtFirstRow) { if (historyIndex == null) { const currentDraft = editorRef.current?.getText?.() ?? input; setDraftInput(currentDraft); if (DEBUG_HIST) { // eslint-disable-next-line no-console console.log("[TCI] store draft", JSON.stringify(currentDraft)); } } let newIndex: number; if (historyIndex == null) { newIndex = history.length - 1; } else { newIndex = Math.max(0, historyIndex - 1); } setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); // Re‑mount the editor so it picks up the new initialText. setEditorKey((k) => k + 1); return; // we handled the key } // Otherwise let the event propagate so the editor moves the caret. } if (_key.downArrow) { if (DEBUG_HIST) { // eslint-disable-next-line no-console console.log("[TCI] downArrow", { historyIndex, draftInput, input }); } // Only move forward in history when we're already *in* history mode // AND the caret sits on the last line of the buffer (so ↓ within a // multi‑line draft simply moves the caret down). if (historyIndex != null && editorRef.current?.isCursorAtLastRow()) { const newIndex = historyIndex + 1; if (newIndex >= history.length) { setHistoryIndex(null); setInput(draftInput); setEditorKey((k) => k + 1); } else { setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); setEditorKey((k) => k + 1); } return; // handled } // Otherwise let it propagate. } } if (input.trim() === "") { if (_key.tab) { setSelectedSuggestion( (s) => (s + (_key.shift ? -1 : 1)) % (suggestions.length + 1), ); } else if (selectedSuggestion && _key.return) { const suggestion = suggestions[selectedSuggestion - 1] || ""; setInput(""); setSelectedSuggestion(0); submitInput([ { role: "user", content: [{ type: "input_text", text: suggestion }], type: "message", }, ]); } } else if (_input === "\u0003" || (_input === "c" && _key.ctrl)) { setTimeout(() => { app.exit(); onExit(); process.exit(0); }, 60); } // Update the cached cursor position *after* we've potentially handled // the key so that the next event has the correct "previous" reference. prevCursorRow.current = editorRef.current?.getRow?.() ?? null; }, { isActive: active }, ); const onSubmit = useCallback( async (value: string) => { const inputValue = value.trim(); if (!inputValue) { return; } if (inputValue === "/history") { setInput(""); openOverlay(); return; } if (inputValue === "/help") { setInput(""); openHelpOverlay(); return; } if (inputValue === "/diff") { setInput(""); openDiffOverlay(); return; } if (inputValue.startsWith("/model")) { setInput(""); openModelOverlay(); return; } if (inputValue.startsWith("/approval")) { setInput(""); openApprovalOverlay(); return; } if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") { setInput(""); // wait one 60ms frame setTimeout(() => { app.exit(); onExit(); process.exit(0); }, 60); return; } else if (inputValue === "/clear" || inputValue === "clear") { setInput(""); setSessionId(""); setLastResponseId(""); // Clear the terminal screen (including scrollback) before resetting context clearTerminal(); // Print a clear confirmation and reset conversation items. setItems([ { id: `clear-${Date.now()}`, type: "message", role: "system", content: [{ type: "input_text", text: "Terminal cleared" }], }, ]); return; } else if (inputValue === "/clearhistory") { setInput(""); // Import clearCommandHistory function to avoid circular dependencies // Using dynamic import to lazy-load the function import("../../utils/storage/command-history.js").then( async ({ clearCommandHistory }) => { await clearCommandHistory(); setHistory([]); // Emit a system message to confirm the history clear action setItems((prev) => [ ...prev, { id: `clearhistory-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: "Command history cleared" }, ], }, ]); }, ); return; } const images: Array = []; const text = inputValue .replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => { images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1); return ""; }) .trim(); const inputItem = await createInputItem(text, images); submitInput([inputItem]); // Get config for history persistence const config = loadConfig(); // Add to history and update state const updatedHistory = await addToHistory(value, history, { maxSize: config.history?.maxSize ?? 1000, saveHistory: config.history?.saveHistory ?? true, sensitivePatterns: config.history?.sensitivePatterns ?? [], }); setHistory(updatedHistory); setHistoryIndex(null); setDraftInput(""); setSelectedSuggestion(0); setInput(""); }, [ setInput, submitInput, setLastResponseId, setItems, app, setHistory, setHistoryIndex, openOverlay, openApprovalOverlay, openModelOverlay, openHelpOverlay, openDiffOverlay, history, // Add history to the dependency array ], ); if (confirmationPrompt) { return ( ); } return ( {loading ? ( ) : ( <> setInput(txt)} key={editorKey} initialText={input} height={8} focus={active} onSubmit={(txt) => { onSubmit(txt); setEditorKey((k) => k + 1); setInput(""); setHistoryIndex(null); setDraftInput(""); }} /> {!input ? ( <> try:{" "} {suggestions.map((m, key) => ( {key !== 0 ? " | " : ""} {m} ))} ) : ( <> {typeHelpText} {contextLeftPercent < 25 && ( <> {" — "} {Math.round(contextLeftPercent)}% context left )} )} )} ); } function TerminalChatInputThinking({ onInterrupt, active, thinkingSeconds, }: { onInterrupt: () => void; active: boolean; thinkingSeconds: number; }) { const [awaitingConfirm, setAwaitingConfirm] = useState(false); const [dots, setDots] = useState(""); // Animate ellipsis useInterval(() => { setDots((prev) => (prev.length < 3 ? prev + "." : "")); }, 500); // Spinner frames with seconds embedded const ballFrames = [ "( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )", "( ● )", "( ● )", "(● )", ]; const [frame, setFrame] = useState(0); useInterval(() => { setFrame((idx) => (idx + 1) % ballFrames.length); }, 80); const frameTemplate = ballFrames[frame] ?? ballFrames[0]; const frameWithSeconds = (frameTemplate as string).replace( "●", `●${thinkingSeconds}s`, ); // --------------------------------------------------------------------- // Raw stdin listener to catch the case where the terminal delivers two // consecutive ESC bytes ("\x1B\x1B") in a *single* chunk. Ink's `useInput` // collapses that sequence into one key event, so the regular two‑step // handler above never sees the second press. By inspecting the raw data // we can identify this special case and trigger the interrupt while still // requiring a double press for the normal single‑byte ESC events. // --------------------------------------------------------------------- const { stdin, setRawMode } = useStdin(); React.useEffect(() => { if (!active) { return; } // Ensure raw mode – already enabled by Ink when the component has focus, // but called defensively in case that assumption ever changes. setRawMode?.(true); const onData = (data: Buffer | string) => { if (awaitingConfirm) { return; // already awaiting a second explicit press } // Handle both Buffer and string forms. const str = Buffer.isBuffer(data) ? data.toString("utf8") : data; if (str === "\x1b\x1b") { // Treat as the first Escape press – prompt the user for confirmation. log( "raw stdin: received collapsed ESC ESC – starting confirmation timer", ); setAwaitingConfirm(true); setTimeout(() => setAwaitingConfirm(false), 1500); } }; stdin?.on("data", onData); return () => { stdin?.off("data", onData); }; }, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]); // Elapsed time provided via props – no local interval needed. useInput( (_input, key) => { if (!key.escape) { return; } if (awaitingConfirm) { log("useInput: second ESC detected – triggering onInterrupt()"); onInterrupt(); setAwaitingConfirm(false); } else { log("useInput: first ESC detected – waiting for confirmation"); setAwaitingConfirm(true); setTimeout(() => setAwaitingConfirm(false), 1500); } }, { isActive: active }, ); return ( {frameWithSeconds} Thinking {dots} {awaitingConfirm && ( Press Esc again to interrupt and enter a new instruction )} ); }