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:
@@ -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
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 ?? "");
|
||||
// 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
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user