mirror of
https://github.com/openai/codex.git
synced 2026-04-28 02:11:08 +03:00
## What does this PR do?
* Implements the full `/clear` command in **codex‑cli**:
* Resets chat history **and** wipes the terminal screen.
* Shows a single system message: `Context cleared`.
* Adds comprehensive unit tests for the new behaviour.
## Why is it needed?
* Fixes user‑reported bugs:
* **#395**
* **#405**
## How is it implemented?
* **Code** – Adds `process.stdout.write('\x1b[3J\x1b[H\x1b[2J')` in
`terminal.tsx`. Removed reference to `prev` in `
setItems((prev) => [
...prev,
` in `terminal-chat-new-input.tsx` & `terminal-chat-input.tsx`.
## CI / QA
All commands pass locally:
```bash
pnpm test # green
pnpm run lint # green
pnpm run typecheck # zero TS errors
```
## Results
https://github.com/user-attachments/assets/11dcf05c-e054-495a-8ecb-ac6ef21a9da4
---------
Co-authored-by: Thibault Sottiaux <tibo@openai.com>
561 lines
17 KiB
TypeScript
561 lines
17 KiB
TypeScript
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<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;
|
||
openDiffOverlay: () => void;
|
||
interruptAgent: () => void;
|
||
active: boolean;
|
||
thinkingSeconds: number;
|
||
}): 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>("");
|
||
// 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<MultilineTextEditorHandle | null>(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<number | null>(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<string> = [];
|
||
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 (
|
||
<TerminalChatCommandReview
|
||
confirmationPrompt={confirmationPrompt}
|
||
onReviewCommand={submitConfirmation}
|
||
// allow switching approval mode via 'v'
|
||
onSwitchApprovalMode={openApprovalOverlay}
|
||
explanation={explanation}
|
||
// disable when input is inactive (e.g., overlay open)
|
||
isActive={active}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box flexDirection="column">
|
||
{loading ? (
|
||
<Box borderStyle="round">
|
||
<TerminalChatInputThinking
|
||
onInterrupt={interruptAgent}
|
||
active={active}
|
||
thinkingSeconds={thinkingSeconds}
|
||
/>
|
||
</Box>
|
||
) : (
|
||
<>
|
||
<Box borderStyle="round">
|
||
<MultilineTextEditor
|
||
ref={editorRef}
|
||
onChange={(txt: string) => setInput(txt)}
|
||
key={editorKey}
|
||
initialText={input}
|
||
height={8}
|
||
focus={active}
|
||
onSubmit={(txt) => {
|
||
onSubmit(txt);
|
||
|
||
setEditorKey((k) => k + 1);
|
||
|
||
setInput("");
|
||
setHistoryIndex(null);
|
||
setDraftInput("");
|
||
}}
|
||
/>
|
||
</Box>
|
||
<Box paddingX={2} marginBottom={1}>
|
||
<Text dimColor>
|
||
{!input ? (
|
||
<>
|
||
try:{" "}
|
||
{suggestions.map((m, key) => (
|
||
<Fragment key={key}>
|
||
{key !== 0 ? " | " : ""}
|
||
<Text
|
||
backgroundColor={
|
||
key + 1 === selectedSuggestion ? "blackBright" : ""
|
||
}
|
||
>
|
||
{m}
|
||
</Text>
|
||
</Fragment>
|
||
))}
|
||
</>
|
||
) : (
|
||
<>
|
||
{typeHelpText}
|
||
{contextLeftPercent < 25 && (
|
||
<>
|
||
{" — "}
|
||
<Text color="red">
|
||
{Math.round(contextLeftPercent)}% context left
|
||
</Text>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</Text>
|
||
</Box>
|
||
</>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Box flexDirection="column" gap={1}>
|
||
<Box gap={2}>
|
||
<Text>{frameWithSeconds}</Text>
|
||
<Text>
|
||
Thinking
|
||
{dots}
|
||
</Text>
|
||
</Box>
|
||
{awaitingConfirm && (
|
||
<Text dimColor>
|
||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||
instruction
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|