feat: add command history persistence (#152)

This PR adds a command history persistence feature to Codex CLI that:

1. **Stores command history**: Commands are saved to
`~/.codex/history.json` and persist between CLI sessions.
2. **Navigates history**: Users can use the up/down arrow keys to
navigate through command history, similar to a traditional shell.
3. **Filters sensitive data**: Built-in regex patterns prevent commands
containing API keys, passwords, or tokens from being saved.
4. **Configurable**: Added configuration options for history size,
enabling/disabling history, and custom regex patterns for sensitive
content.
5. **New command**: Added `/clearhistory` command to clear command
history.

  ## Code Changes

- Added `src/utils/storage/command-history.ts` with functions for
history management
  - Extended config system to support history settings
  - Updated terminal input components to use persistent history
  - Added help text for the new `/clearhistory` command
  - Added CLAUDE.md file for guidance when working with the codebase

  ## Testing

  - All tests are passing
- Core functionality works with both input components (standard and
multiline)
- History navigation behaves correctly at line boundaries with the
multiline editor
This commit is contained in:
Tomas Cupr
2025-04-17 21:41:54 +02:00
committed by GitHub
parent 5e1d149eb5
commit 295079cf33
6 changed files with 326 additions and 29 deletions

View File

@@ -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<number>(0);
const [input, setInput] = useState("");
const [history, setHistory] = useState<Array<string>>([]);
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) {
@@ -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
],
);

View File

@@ -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<number>(0);
const [input, setInput] = useState("");
const [history, setHistory] = useState<Array<string>>([]);
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
@@ -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<MultilineTextEditorHandle | null>(null);
@@ -159,7 +175,7 @@ export default function TerminalChatInput({
newIndex = Math.max(0, historyIndex - 1);
}
setHistoryIndex(newIndex);
setInput(history[newIndex] ?? "");
setInput(history[newIndex]?.command ?? "");
// Remount 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
],
);

View File

@@ -49,6 +49,9 @@ export default function HelpOverlay({
<Text>
<Text color="cyan">/clear</Text> clear screen &amp; context
</Text>
<Text>
<Text color="cyan">/clearhistory</Text> clear command history
</Text>
<Box marginTop={1}>
<Text bold dimColor>

View File

@@ -49,6 +49,11 @@ export type StoredConfig = {
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
history?: {
maxSize?: number;
saveHistory?: boolean;
sensitivePatterns?: Array<string>;
};
};
// 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<string>;
};
};
// ---------------------------------------------------------------------------
@@ -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");

View File

@@ -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<string>; // 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<Array<HistoryEntry>> {
try {
if (!existsSync(HISTORY_FILE)) {
return [];
}
const data = await fs.readFile(HISTORY_FILE, "utf-8");
const history = JSON.parse(data) as Array<HistoryEntry>;
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<HistoryEntry>,
config: HistoryConfig = DEFAULT_HISTORY_CONFIG,
): Promise<void> {
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<HistoryEntry>,
config: HistoryConfig = DEFAULT_HISTORY_CONFIG,
): Promise<Array<HistoryEntry>> {
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<string> = [],
): 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<void> {
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);
}
}