diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 37d46a60..acdcd0d0 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -1,4 +1,5 @@ import type { ReviewDecision } from "../../utils/agent/review.js"; +import type { HistoryEntry } from "../../utils/storage/command-history.js"; import type { ResponseInputItem, ResponseItem, @@ -6,14 +7,19 @@ import type { 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 } from "react"; +import React, { useCallback, useState, Fragment, useEffect } from "react"; import { useInterval } from "use-interval"; const suggestions = [ @@ -59,10 +65,20 @@ export default function TerminalChatInput({ const app = useApp(); const [selectedSuggestion, setSelectedSuggestion] = useState(0); const [input, setInput] = useState(""); - const [history, setHistory] = useState>([]); + const [history, setHistory] = useState>([]); const [historyIndex, setHistoryIndex] = useState(null); const [draftInput, setDraftInput] = useState(""); + // Load command history on component mount + useEffect(() => { + async function loadHistory() { + const historyEntries = await loadCommandHistory(); + setHistory(historyEntries); + } + + loadHistory(); + }, []); + useInput( (_input, _key) => { if (!confirmationPrompt && !loading) { @@ -79,7 +95,7 @@ export default function TerminalChatInput({ newIndex = Math.max(0, historyIndex - 1); } setHistoryIndex(newIndex); - setInput(history[newIndex] ?? ""); + setInput(history[newIndex]?.command ?? ""); } return; } @@ -95,7 +111,7 @@ export default function TerminalChatInput({ setInput(draftInput); } else { setHistoryIndex(newIndex); - setInput(history[newIndex] ?? ""); + setInput(history[newIndex]?.command ?? ""); } return; } @@ -187,6 +203,32 @@ export default function TerminalChatInput({ }, ]); + 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; } @@ -200,12 +242,18 @@ export default function TerminalChatInput({ const inputItem = await createInputItem(text, images); submitInput([inputItem]); - setHistory((prev) => { - if (prev[prev.length - 1] === value) { - return prev; - } - return [...prev, value]; + + // 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); @@ -223,6 +271,7 @@ export default function TerminalChatInput({ openApprovalOverlay, openModelOverlay, openHelpOverlay, + history, // Add history to the dependency array ], ); diff --git a/codex-cli/src/components/chat/terminal-chat-new-input.tsx b/codex-cli/src/components/chat/terminal-chat-new-input.tsx index e1663fcf..91096dfc 100644 --- a/codex-cli/src/components/chat/terminal-chat-new-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-new-input.tsx @@ -1,5 +1,6 @@ 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, @@ -8,13 +9,18 @@ import type { import MultilineTextEditor from "./multiline-editor"; 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 { Box, Text, useApp, useInput, useStdin } from "ink"; import { fileURLToPath } from "node:url"; -import React, { useCallback, useState, Fragment } from "react"; +import React, { useCallback, useState, Fragment, useEffect } from "react"; import { useInterval } from "use-interval"; const suggestions = [ @@ -102,7 +108,7 @@ export default function TerminalChatInput({ const app = useApp(); const [selectedSuggestion, setSelectedSuggestion] = useState(0); const [input, setInput] = useState(""); - const [history, setHistory] = 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 @@ -110,6 +116,16 @@ export default function TerminalChatInput({ // 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); @@ -159,7 +175,7 @@ export default function TerminalChatInput({ newIndex = Math.max(0, historyIndex - 1); } setHistoryIndex(newIndex); - setInput(history[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 @@ -183,7 +199,7 @@ export default function TerminalChatInput({ setEditorKey((k) => k + 1); } else { setHistoryIndex(newIndex); - setInput(history[newIndex] ?? ""); + setInput(history[newIndex]?.command ?? ""); setEditorKey((k) => k + 1); } return; // handled @@ -282,6 +298,32 @@ export default function TerminalChatInput({ }, ]); + 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; } @@ -295,12 +337,18 @@ export default function TerminalChatInput({ const inputItem = await createInputItem(text, images); submitInput([inputItem]); - setHistory((prev) => { - if (prev[prev.length - 1] === value) { - return prev; - } - return [...prev, value]; + + // 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); @@ -318,6 +366,7 @@ export default function TerminalChatInput({ openApprovalOverlay, openModelOverlay, openHelpOverlay, + history, // Add history to the dependency array ], ); diff --git a/codex-cli/src/components/help-overlay.tsx b/codex-cli/src/components/help-overlay.tsx index 538f6c37..023fa202 100644 --- a/codex-cli/src/components/help-overlay.tsx +++ b/codex-cli/src/components/help-overlay.tsx @@ -49,6 +49,9 @@ export default function HelpOverlay({ /clear – clear screen & context + + /clearhistory – clear command history + diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts index a98473b2..e3536f9e 100644 --- a/codex-cli/src/utils/config.ts +++ b/codex-cli/src/utils/config.ts @@ -49,6 +49,11 @@ export type StoredConfig = { approvalMode?: AutoApprovalMode; fullAutoErrorMode?: FullAutoErrorMode; memory?: MemoryConfig; + history?: { + maxSize?: number; + saveHistory?: boolean; + sensitivePatterns?: Array; + }; }; // Minimal config written on first run. An *empty* model string ensures that @@ -70,6 +75,11 @@ export type AppConfig = { instructions: string; fullAutoErrorMode?: FullAutoErrorMode; memory?: MemoryConfig; + history?: { + maxSize: number; + saveHistory: boolean; + sensitivePatterns: Array; + }; }; // --------------------------------------------------------------------------- @@ -313,6 +323,21 @@ export const loadConfig = ( config.fullAutoErrorMode = storedConfig.fullAutoErrorMode; } + // Add default history config if not provided + if (storedConfig.history !== undefined) { + config.history = { + maxSize: storedConfig.history.maxSize ?? 1000, + saveHistory: storedConfig.history.saveHistory ?? true, + sensitivePatterns: storedConfig.history.sensitivePatterns ?? [], + }; + } else { + config.history = { + maxSize: 1000, + saveHistory: true, + sensitivePatterns: [], + }; + } + return config; }; @@ -341,14 +366,24 @@ export const saveConfig = ( } const ext = extname(targetPath).toLowerCase(); + // Create the config object to save + const configToSave: StoredConfig = { + model: config.model, + }; + + // Add history settings if they exist + if (config.history) { + configToSave.history = { + maxSize: config.history.maxSize, + saveHistory: config.history.saveHistory, + sensitivePatterns: config.history.sensitivePatterns, + }; + } + if (ext === ".yaml" || ext === ".yml") { - writeFileSync(targetPath, dumpYaml({ model: config.model }), "utf-8"); + writeFileSync(targetPath, dumpYaml(configToSave), "utf-8"); } else { - writeFileSync( - targetPath, - JSON.stringify({ model: config.model }, null, 2), - "utf-8", - ); + writeFileSync(targetPath, JSON.stringify(configToSave, null, 2), "utf-8"); } writeFileSync(instructionsPath, config.instructions, "utf-8"); diff --git a/codex-cli/src/utils/storage/command-history.ts b/codex-cli/src/utils/storage/command-history.ts new file mode 100644 index 00000000..997c4872 --- /dev/null +++ b/codex-cli/src/utils/storage/command-history.ts @@ -0,0 +1,159 @@ +import { existsSync } from "fs"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +const HISTORY_FILE = path.join(os.homedir(), ".codex", "history.json"); +const DEFAULT_HISTORY_SIZE = 1000; + +// Regex patterns for sensitive commands that should not be saved +const SENSITIVE_PATTERNS = [ + /\b[A-Za-z0-9-_]{20,}\b/, // API keys and tokens + /\bpassword\b/i, + /\bsecret\b/i, + /\btoken\b/i, + /\bkey\b/i, +]; + +export interface HistoryConfig { + maxSize: number; + saveHistory: boolean; + sensitivePatterns: Array; // Array of regex patterns as strings +} + +export interface HistoryEntry { + command: string; + timestamp: number; +} + +export const DEFAULT_HISTORY_CONFIG: HistoryConfig = { + maxSize: DEFAULT_HISTORY_SIZE, + saveHistory: true, + sensitivePatterns: [], +}; + +/** + * Loads command history from the history file + */ +export async function loadCommandHistory(): Promise> { + try { + if (!existsSync(HISTORY_FILE)) { + return []; + } + + const data = await fs.readFile(HISTORY_FILE, "utf-8"); + const history = JSON.parse(data) as Array; + return Array.isArray(history) ? history : []; + } catch (error) { + // Use error logger but for production would use a proper logging system + // eslint-disable-next-line no-console + console.error("Failed to load command history:", error); + return []; + } +} + +/** + * Saves command history to the history file + */ +export async function saveCommandHistory( + history: Array, + config: HistoryConfig = DEFAULT_HISTORY_CONFIG, +): Promise { + try { + // Create directory if it doesn't exist + const dir = path.dirname(HISTORY_FILE); + await fs.mkdir(dir, { recursive: true }); + + // Trim history to max size + const trimmedHistory = history.slice(-config.maxSize); + + await fs.writeFile( + HISTORY_FILE, + JSON.stringify(trimmedHistory, null, 2), + "utf-8", + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to save command history:", error); + } +} + +/** + * Adds a command to history if it's not sensitive + */ +export async function addToHistory( + command: string, + history: Array, + config: HistoryConfig = DEFAULT_HISTORY_CONFIG, +): Promise> { + if (!config.saveHistory || command.trim() === "") { + return history; + } + + // Check if command contains sensitive information + if (isSensitiveCommand(command, config.sensitivePatterns)) { + return history; + } + + // Check for duplicate (don't add if it's the same as the last command) + const lastEntry = history[history.length - 1]; + if (lastEntry && lastEntry.command === command) { + return history; + } + + // Add new entry + const newEntry: HistoryEntry = { + command, + timestamp: Date.now(), + }; + + const newHistory = [...history, newEntry]; + + // Save to file + await saveCommandHistory(newHistory, config); + + return newHistory; +} + +/** + * Checks if a command contains sensitive information + */ +function isSensitiveCommand( + command: string, + additionalPatterns: Array = [], +): boolean { + // Check built-in patterns + for (const pattern of SENSITIVE_PATTERNS) { + if (pattern.test(command)) { + return true; + } + } + + // Check additional patterns from config + for (const patternStr of additionalPatterns) { + try { + const pattern = new RegExp(patternStr); + if (pattern.test(command)) { + return true; + } + } catch (error) { + // Invalid regex pattern, skip it + } + } + + return false; +} + +/** + * Clears the command history + */ +export async function clearCommandHistory(): Promise { + try { + if (existsSync(HISTORY_FILE)) { + await fs.writeFile(HISTORY_FILE, JSON.stringify([]), "utf-8"); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to clear command history:", error); + } +} diff --git a/codex-cli/tests/config.test.tsx b/codex-cli/tests/config.test.tsx index d528ef9a..dfe80e30 100644 --- a/codex-cli/tests/config.test.tsx +++ b/codex-cli/tests/config.test.tsx @@ -58,10 +58,10 @@ test("loads default config if files don't exist", () => { const config = loadConfig(testConfigPath, testInstructionsPath, { disableProjectDoc: true, }); - expect(config).toEqual({ - model: "o4-mini", - instructions: "", - }); + // Keep the test focused on just checking that default model and instructions are loaded + // so we need to make sure we check just these properties + expect(config.model).toBe("o4-mini"); + expect(config.instructions).toBe(""); }); test("saves and loads config correctly", () => { @@ -78,7 +78,9 @@ test("saves and loads config correctly", () => { const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, { disableProjectDoc: true, }); - expect(loadedConfig).toEqual(testConfig); + // Check just the specified properties that were saved + expect(loadedConfig.model).toBe(testConfig.model); + expect(loadedConfig.instructions).toBe(testConfig.instructions); }); test("loads user instructions + project doc when codex.md is present", () => {