### What is added? I extend the if-else blocks with an additional condition where the commands validity is checked. This only applies for entered inputs that start with '/' and are a single word. This isn't necessarily restrictive from the previous behavior of the program. When an invalid command is detected, an error message is printed with a direction to retrieve command list. ### Why is it added? There are three main reasons for this change **1. Model Hallucination**: When invalid commands are passed as prompts to models, models hallucinate behavior. Since there was a fall through in invalid commands, the models took these as prompts and hallucinated that they completed the prompted task. An example of this behavior is below. In the case of this example, the model though they had access to `/clearhistory` tool where in reality that isn't the case. A before and after effect of this tool is below:   **2. Saves Users Credits and Time**: Since false commands and invalid commands aren't processed by the model, the user doesn't spend money on stuff that could have been mitigated much easily. The time argument is especially applicable for reasoning models. **3. Saves GPU Time**: GPU time is valuable, and it is not necessary to spend it on unnecessary/invalid requests. ### Code Explanation If no command is matched, we check if the inputValue start with `/` which indicated the input is a command (I will address the case where it is isn't below). If the inputValue start with `/` we enter the else if statement. I used a nested if statement for readability and further extendability in the future. There are alternative ways to check besides regex, but regex is a short code and looks clean. **Check Conditions**: The reason why I only check single word(command) case is that to allow prompts where the user might decide to start with `/` and aren't commands. The nested if statements also come in handy where in the future other contributors might want to extend this checking. The code passes type, lint and test checks.
518 lines
15 KiB
TypeScript
518 lines
15 KiB
TypeScript
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 {
|
||
loadCommandHistory,
|
||
addToHistory,
|
||
} from "../../utils/storage/command-history.js";
|
||
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";
|
||
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,
|
||
}: {
|
||
isNew: boolean;
|
||
loading: boolean;
|
||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||
confirmationPrompt: React.ReactNode | null;
|
||
explanation?: string;
|
||
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;
|
||
onCompact: () => void;
|
||
interruptAgent: () => void;
|
||
active: boolean;
|
||
}): React.ReactElement {
|
||
const app = useApp();
|
||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||
const [input, setInput] = useState("");
|
||
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
|
||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||
const [draftInput, setDraftInput] = useState<string>("");
|
||
|
||
// Load command history on component mount
|
||
useEffect(() => {
|
||
async function loadHistory() {
|
||
const historyEntries = await loadCommandHistory();
|
||
setHistory(historyEntries);
|
||
}
|
||
|
||
loadHistory();
|
||
}, []);
|
||
|
||
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);
|
||
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 (!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 <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" }],
|
||
},
|
||
]);
|
||
|
||
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.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<string> = [];
|
||
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) => {
|
||
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, // Add history to the dependency array
|
||
onCompact,
|
||
],
|
||
);
|
||
|
||
if (confirmationPrompt) {
|
||
return (
|
||
<TerminalChatCommandReview
|
||
confirmationPrompt={confirmationPrompt}
|
||
onReviewCommand={submitConfirmation}
|
||
explanation={explanation}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{Math.round(contextLeftPercent)}% context left — send
|
||
"/compact" to condense context
|
||
</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>
|
||
);
|
||
}
|