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 { TerminalChatCommandReview } from "./terminal-chat-command-review.js"; import { log, isLoggingEnabled } from "../../utils/agent/log.js"; import { loadConfig } from "../../utils/config.js"; import { createInputItem } from "../../utils/input-utils.js"; import { setSessionId } from "../../utils/session.js"; import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands"; import { loadCommandHistory, addToHistory, } from "../../utils/storage/command-history.js"; import { clearTerminal, onExit } from "../../utils/terminal.js"; import TextInput from "../vendor/ink-text-input.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?", ]; export default function TerminalChatInput({ isNew, loading, submitInput, confirmationPrompt, explanation, submitConfirmation, setLastResponseId, setItems, contextLeftPercent, openOverlay, openModelOverlay, openApprovalOverlay, openHelpOverlay, onCompact, interruptAgent, active, thinkingSeconds, items = [], }: { 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; onCompact: () => void; interruptAgent: () => void; active: boolean; thinkingSeconds: number; // New: current conversation items so we can include them in bug reports items?: Array; }): React.ReactElement { // Slash command suggestion index const [selectedSlashSuggestion, setSelectedSlashSuggestion] = useState(0); 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(""); const [skipNextSubmit, setSkipNextSubmit] = useState(false); // Load command history on component mount useEffect(() => { async function loadHistory() { const historyEntries = await loadCommandHistory(); setHistory(historyEntries); } loadHistory(); }, []); // Reset slash suggestion index when input prefix changes useEffect(() => { if (input.trim().startsWith("/")) { setSelectedSlashSuggestion(0); } }, [input]); useInput( (_input, _key) => { // Slash command navigation: up/down to select, enter to fill if (!confirmationPrompt && !loading && input.trim().startsWith("/")) { const prefix = input.trim(); const matches = SLASH_COMMANDS.filter((cmd: SlashCommand) => cmd.command.startsWith(prefix), ); if (matches.length > 0) { if (_key.tab) { // Cycle and fill slash command suggestions on Tab const len = matches.length; // Determine new index based on shift state const nextIdx = _key.shift ? selectedSlashSuggestion <= 0 ? len - 1 : selectedSlashSuggestion - 1 : selectedSlashSuggestion >= len - 1 ? 0 : selectedSlashSuggestion + 1; setSelectedSlashSuggestion(nextIdx); // Autocomplete the command in the input const match = matches[nextIdx]; if (!match) { return; } const cmd = match.command; setInput(cmd); setDraftInput(cmd); return; } if (_key.upArrow) { setSelectedSlashSuggestion((prev) => prev <= 0 ? matches.length - 1 : prev - 1, ); return; } if (_key.downArrow) { setSelectedSlashSuggestion((prev) => prev < 0 || prev >= matches.length - 1 ? 0 : prev + 1, ); return; } if (_key.return) { // Execute the currently selected slash command const selIdx = selectedSlashSuggestion; const cmdObj = matches[selIdx]; if (cmdObj) { const cmd = cmdObj.command; setInput(""); setDraftInput(""); setSelectedSlashSuggestion(0); switch (cmd) { case "/history": openOverlay(); break; case "/help": openHelpOverlay(); break; case "/compact": onCompact(); break; case "/model": openModelOverlay(); break; case "/approval": openApprovalOverlay(); break; case "/bug": onSubmit(cmd); break; default: break; } } return; } } } if (!confirmationPrompt && !loading) { if (_key.upArrow) { if (history.length > 0) { if (historyIndex == null) { setDraftInput(input); } let newIndex: number; if (historyIndex == null) { newIndex = history.length - 1; } else { newIndex = Math.max(0, historyIndex - 1); } setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); } return; } if (_key.downArrow) { if (historyIndex == null) { return; } const newIndex = historyIndex + 1; if (newIndex >= history.length) { setHistoryIndex(null); setInput(draftInput); } else { setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); } return; } } if (input.trim() === "" && isNew) { 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); } }, { isActive: active }, ); const onSubmit = useCallback( async (value: string) => { const inputValue = value.trim(); // If the user only entered a slash, do not send a chat message if (inputValue === "/") { setInput(""); return; } // Skip this submit if we just autocompleted a slash command if (skipNextSubmit) { setSkipNextSubmit(false); return; } if (!inputValue) { return; } if (inputValue === "/history") { setInput(""); openOverlay(); return; } if (inputValue === "/help") { setInput(""); openHelpOverlay(); return; } if (inputValue === "/compact") { setInput(""); onCompact(); 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(""); clearTerminal(); // Emit a system message to confirm the clear action. We *append* // it so Ink's treats it as new output and actually renders it. setItems((prev) => [ ...prev, { id: `clear-${Date.now()}`, type: "message", role: "system", content: [{ type: "input_text", text: "Context 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; } else if (inputValue === "/bug") { // Generate a GitHub bug report URL pre‑filled with session details setInput(""); try { // Dynamically import dependencies to avoid unnecessary bundle size const [{ default: open }, os] = await Promise.all([ import("open"), import("node:os"), ]); // Lazy import CLI_VERSION to avoid circular deps const { CLI_VERSION } = await import("../../utils/session.js"); const { buildBugReportUrl } = await import( "../../utils/bug-report.js" ); const url = buildBugReportUrl({ items: items ?? [], cliVersion: CLI_VERSION, model: loadConfig().model ?? "unknown", platform: [os.platform(), os.arch(), os.release()] .map((s) => `\`${s}\``) .join(" | "), }); // Open the URL in the user's default browser await open(url, { wait: false }); // Inform the user in the chat history setItems((prev) => [ ...prev, { id: `bugreport-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: "📋 Opened browser to file a bug report. Please include any context that might help us fix the issue!", }, ], }, ]); } catch (error) { // If anything went wrong, notify the user setItems((prev) => [ ...prev, { id: `bugreport-error-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: `⚠️ Failed to create bug report URL: ${error}`, }, ], }, ]); } return; } else if (inputValue.startsWith("/")) { // Handle invalid/unrecognized commands. // Only single-word inputs starting with '/' (e.g., /command) that are not recognized are caught here. // Any other input, including those starting with '/' but containing spaces // (e.g., "/command arg"), will fall through and be treated as a regular prompt. const trimmed = inputValue.trim(); if (/^\/\S+$/.test(trimmed)) { setInput(""); setItems((prev) => [ ...prev, { id: `invalidcommand-${Date.now()}`, type: "message", role: "system", content: [ { type: "input_text", text: `Invalid command "${trimmed}". Use /help to retrieve the list of commands.`, }, ], }, ]); return; } } // detect image file paths for dynamic inclusion const images: Array = []; let text = inputValue; // markdown-style image syntax: ![alt](path) text = text.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => { images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1); return ""; }); // quoted file paths ending with common image extensions (e.g. '/path/to/img.png') text = text.replace( /['"]([^'"]+?\.(?:png|jpe?g|gif|bmp|webp|svg))['"]/gi, (_m, p1: string) => { images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1); return ""; }, ); // bare file paths ending with common image extensions text = text.replace( // eslint-disable-next-line no-useless-escape /\b(?:\.[\/\\]|[\/\\]|[A-Za-z]:[\/\\])?[\w-]+(?:[\/\\][\w-]+)*\.(?:png|jpe?g|gif|bmp|webp|svg)\b/gi, (match: string) => { images.push( match.startsWith("file://") ? fileURLToPath(match) : match, ); return ""; }, ); text = text.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, history, onCompact, skipNextSubmit, items, ], ); if (confirmationPrompt) { return ( ); } return ( {loading ? ( ) : ( { setDraftInput(value); if (historyIndex != null) { setHistoryIndex(null); } setInput(value); }} onSubmit={onSubmit} /> )} {/* Slash command autocomplete suggestions */} {input.trim().startsWith("/") && ( {SLASH_COMMANDS.filter((cmd: SlashCommand) => cmd.command.startsWith(input.trim()), ).map((cmd: SlashCommand, idx: number) => ( {cmd.command} {cmd.description} ))} )} {isNew && !input ? ( <> try:{" "} {suggestions.map((m, key) => ( {key !== 0 ? " | " : ""} {m} ))} ) : ( <> send q or ctrl+c to exit | send "/clear" to reset | send "/help" for commands | press enter to send {contextLeftPercent > 25 && ( <> {" — "} 40 ? "green" : "yellow"}> {Math.round(contextLeftPercent)}% context left )} {contextLeftPercent <= 25 && ( <> {" — "} {Math.round(contextLeftPercent)}% context left — send "/compact" to condense context )} )} ); } 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 embedded seconds const ballFrames = [ "( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )", "( ● )", "( ● )", "(● )", ]; const [frame, setFrame] = useState(0); useInterval(() => { setFrame((idx) => (idx + 1) % ballFrames.length); }, 80); // Keep the elapsed‑seconds text fixed while the ball animation moves. const frameTemplate = ballFrames[frame] ?? ballFrames[0]; const frameWithSeconds = `${frameTemplate} ${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. if (isLoggingEnabled()) { 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]); // No local timer: the parent component supplies the elapsed time via props. // Listen for the escape key to allow the user to interrupt the current // operation. We require two presses within a short window (1.5s) to avoid // accidental cancellations. useInput( (_input, key) => { if (!key.escape) { return; } if (awaitingConfirm) { if (isLoggingEnabled()) { log("useInput: second ESC detected – triggering onInterrupt()"); } onInterrupt(); setAwaitingConfirm(false); } else { if (isLoggingEnabled()) { 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 )} ); }