2025-04-16 12:56:08 -04:00
|
|
|
|
import type { ReviewDecision } from "../../utils/agent/review.js";
|
2025-04-17 21:41:54 +02:00
|
|
|
|
import type { HistoryEntry } from "../../utils/storage/command-history.js";
|
2025-04-16 12:56:08 -04:00
|
|
|
|
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";
|
2025-04-17 21:41:54 +02:00
|
|
|
|
import { loadConfig } from "../../utils/config.js";
|
2025-04-16 12:56:08 -04:00
|
|
|
|
import { createInputItem } from "../../utils/input-utils.js";
|
|
|
|
|
|
import { setSessionId } from "../../utils/session.js";
|
2025-04-17 21:41:54 +02:00
|
|
|
|
import {
|
|
|
|
|
|
loadCommandHistory,
|
|
|
|
|
|
addToHistory,
|
|
|
|
|
|
} from "../../utils/storage/command-history.js";
|
2025-04-16 12:56:08 -04:00
|
|
|
|
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
|
|
|
|
|
import Spinner from "../vendor/ink-spinner.js";
|
|
|
|
|
|
import TextInput from "../vendor/ink-text-input.js";
|
|
|
|
|
|
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2025-04-17 21:41:54 +02:00
|
|
|
|
import React, { useCallback, useState, Fragment, useEffect } from "react";
|
2025-04-16 12:56:08 -04:00
|
|
|
|
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,
|
2025-04-18 06:28:58 +10:00
|
|
|
|
explanation,
|
2025-04-16 12:56:08 -04:00
|
|
|
|
submitConfirmation,
|
|
|
|
|
|
setLastResponseId,
|
|
|
|
|
|
setItems,
|
|
|
|
|
|
contextLeftPercent,
|
|
|
|
|
|
openOverlay,
|
|
|
|
|
|
openModelOverlay,
|
|
|
|
|
|
openApprovalOverlay,
|
|
|
|
|
|
openHelpOverlay,
|
2025-04-18 15:48:30 +10:00
|
|
|
|
onCompact,
|
2025-04-16 12:56:08 -04:00
|
|
|
|
interruptAgent,
|
|
|
|
|
|
active,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
isNew: boolean;
|
|
|
|
|
|
loading: boolean;
|
|
|
|
|
|
submitInput: (input: Array<ResponseInputItem>) => void;
|
|
|
|
|
|
confirmationPrompt: React.ReactNode | null;
|
2025-04-18 06:28:58 +10:00
|
|
|
|
explanation?: string;
|
2025-04-16 12:56:08 -04:00
|
|
|
|
submitConfirmation: (
|
|
|
|
|
|
decision: ReviewDecision,
|
|
|
|
|
|
customDenyMessage?: string,
|
|
|
|
|
|
) => void;
|
|
|
|
|
|
setLastResponseId: (lastResponseId: string) => void;
|
|
|
|
|
|
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
|
|
|
|
|
|
contextLeftPercent: number;
|
|
|
|
|
|
openOverlay: () => void;
|
|
|
|
|
|
openModelOverlay: () => void;
|
|
|
|
|
|
openApprovalOverlay: () => void;
|
|
|
|
|
|
openHelpOverlay: () => void;
|
2025-04-18 15:48:30 +10:00
|
|
|
|
onCompact: () => void;
|
2025-04-16 12:56:08 -04:00
|
|
|
|
interruptAgent: () => void;
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
}): React.ReactElement {
|
|
|
|
|
|
const app = useApp();
|
|
|
|
|
|
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
|
|
|
|
|
const [input, setInput] = useState("");
|
2025-04-17 21:41:54 +02:00
|
|
|
|
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
|
2025-04-16 12:56:08 -04:00
|
|
|
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
|
|
|
|
|
const [draftInput, setDraftInput] = useState<string>("");
|
|
|
|
|
|
|
2025-04-17 21:41:54 +02:00
|
|
|
|
// Load command history on component mount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadHistory() {
|
|
|
|
|
|
const historyEntries = await loadCommandHistory();
|
|
|
|
|
|
setHistory(historyEntries);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadHistory();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-04-16 12:56:08 -04:00
|
|
|
|
useInput(
|
|
|
|
|
|
(_input, _key) => {
|
|
|
|
|
|
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);
|
2025-04-17 21:41:54 +02:00
|
|
|
|
setInput(history[newIndex]?.command ?? "");
|
2025-04-16 12:56:08 -04:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_key.downArrow) {
|
|
|
|
|
|
if (historyIndex == null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newIndex = historyIndex + 1;
|
|
|
|
|
|
if (newIndex >= history.length) {
|
|
|
|
|
|
setHistoryIndex(null);
|
|
|
|
|
|
setInput(draftInput);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setHistoryIndex(newIndex);
|
2025-04-17 21:41:54 +02:00
|
|
|
|
setInput(history[newIndex]?.command ?? "");
|
2025-04-16 12:56:08 -04:00
|
|
|
|
}
|
|
|
|
|
|
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 (!inputValue) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (inputValue === "/history") {
|
|
|
|
|
|
setInput("");
|
|
|
|
|
|
openOverlay();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (inputValue === "/help") {
|
|
|
|
|
|
setInput("");
|
|
|
|
|
|
openHelpOverlay();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-18 15:48:30 +10:00
|
|
|
|
if (inputValue === "/compact") {
|
|
|
|
|
|
setInput("");
|
|
|
|
|
|
onCompact();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-16 12:56:08 -04:00
|
|
|
|
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 <Static> 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" }],
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2025-04-17 21:41:54 +02:00
|
|
|
|
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" },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-04-16 12:56:08 -04:00
|
|
|
|
return;
|
2025-04-18 07:50:27 +02:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2025-04-16 12:56:08 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-18 09:17:13 +10:00
|
|
|
|
// detect image file paths for dynamic inclusion
|
2025-04-16 12:56:08 -04:00
|
|
|
|
const images: Array<string> = [];
|
2025-04-18 09:17:13 +10:00
|
|
|
|
let text = inputValue;
|
|
|
|
|
|
// markdown-style image syntax: 
|
|
|
|
|
|
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) => {
|
2025-04-16 12:56:08 -04:00
|
|
|
|
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
|
|
|
|
|
return "";
|
2025-04-18 09:17:13 +10:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
// 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();
|
2025-04-16 12:56:08 -04:00
|
|
|
|
|
|
|
|
|
|
const inputItem = await createInputItem(text, images);
|
|
|
|
|
|
submitInput([inputItem]);
|
2025-04-17 21:41:54 +02:00
|
|
|
|
|
|
|
|
|
|
// 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 ?? [],
|
2025-04-16 12:56:08 -04:00
|
|
|
|
});
|
2025-04-17 21:41:54 +02:00
|
|
|
|
|
|
|
|
|
|
setHistory(updatedHistory);
|
2025-04-16 12:56:08 -04:00
|
|
|
|
setHistoryIndex(null);
|
|
|
|
|
|
setDraftInput("");
|
|
|
|
|
|
setSelectedSuggestion(0);
|
|
|
|
|
|
setInput("");
|
|
|
|
|
|
},
|
|
|
|
|
|
[
|
|
|
|
|
|
setInput,
|
|
|
|
|
|
submitInput,
|
|
|
|
|
|
setLastResponseId,
|
|
|
|
|
|
setItems,
|
|
|
|
|
|
app,
|
|
|
|
|
|
setHistory,
|
|
|
|
|
|
setHistoryIndex,
|
|
|
|
|
|
openOverlay,
|
|
|
|
|
|
openApprovalOverlay,
|
|
|
|
|
|
openModelOverlay,
|
|
|
|
|
|
openHelpOverlay,
|
2025-04-17 21:41:54 +02:00
|
|
|
|
history, // Add history to the dependency array
|
2025-04-18 15:48:30 +10:00
|
|
|
|
onCompact,
|
2025-04-16 12:56:08 -04:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (confirmationPrompt) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TerminalChatCommandReview
|
|
|
|
|
|
confirmationPrompt={confirmationPrompt}
|
|
|
|
|
|
onReviewCommand={submitConfirmation}
|
2025-04-18 06:28:58 +10:00
|
|
|
|
explanation={explanation}
|
2025-04-16 12:56:08 -04:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Box flexDirection="column">
|
|
|
|
|
|
<Box borderStyle="round">
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<TerminalChatInputThinking
|
|
|
|
|
|
onInterrupt={interruptAgent}
|
|
|
|
|
|
active={active}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Box paddingX={1}>
|
|
|
|
|
|
<TextInput
|
|
|
|
|
|
focus={active}
|
|
|
|
|
|
placeholder={
|
|
|
|
|
|
selectedSuggestion
|
|
|
|
|
|
? `"${suggestions[selectedSuggestion - 1]}"`
|
|
|
|
|
|
: "send a message" +
|
|
|
|
|
|
(isNew ? " or press tab to select a suggestion" : "")
|
|
|
|
|
|
}
|
|
|
|
|
|
showCursor
|
|
|
|
|
|
value={input}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setDraftInput(value);
|
|
|
|
|
|
if (historyIndex != null) {
|
|
|
|
|
|
setHistoryIndex(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
setInput(value);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onSubmit={onSubmit}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
<Box paddingX={2} marginBottom={1}>
|
|
|
|
|
|
<Text dimColor>
|
|
|
|
|
|
{isNew && !input ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
try:{" "}
|
|
|
|
|
|
{suggestions.map((m, key) => (
|
|
|
|
|
|
<Fragment key={key}>
|
|
|
|
|
|
{key !== 0 ? " | " : ""}
|
|
|
|
|
|
<Text
|
|
|
|
|
|
backgroundColor={
|
|
|
|
|
|
key + 1 === selectedSuggestion ? "blackBright" : ""
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{m}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Fragment>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
|
|
|
|
|
|
for commands | press enter to send
|
|
|
|
|
|
{contextLeftPercent < 25 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{" — "}
|
|
|
|
|
|
<Text color="red">
|
2025-04-18 15:48:30 +10:00
|
|
|
|
{Math.round(contextLeftPercent)}% context left — send
|
|
|
|
|
|
"/compact" to condense context
|
2025-04-16 12:56:08 -04:00
|
|
|
|
</Text>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TerminalChatInputThinking({
|
|
|
|
|
|
onInterrupt,
|
|
|
|
|
|
active,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
onInterrupt: () => void;
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [dots, setDots] = useState("");
|
|
|
|
|
|
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
|
|
|
|
|
|
// Cycle the "Thinking…" animation dots.
|
|
|
|
|
|
useInterval(() => {
|
|
|
|
|
|
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
|
<Box flexDirection="column" gap={1}>
|
|
|
|
|
|
<Box gap={2}>
|
|
|
|
|
|
<Spinner type="ball" />
|
|
|
|
|
|
<Text>Thinking{dots}</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
{awaitingConfirm && (
|
|
|
|
|
|
<Text dimColor>
|
|
|
|
|
|
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
|
|
|
|
|
instruction
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|