From cabf83f2ede2e67d12d33a6cf38f16b1225e8d1d Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Fri, 16 May 2025 12:28:22 -0700 Subject: [PATCH] add: session history viewer (#912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A new “/sessions” command is available for browsing previous sessions, as shown in the updated slash command list - The CLI now documents and parses a new “--history” flag to browse past sessions from the command line - A dedicated `SessionsOverlay` component loads session metadata and allows toggling between viewing and resuming sessions - When the sessions overlay is opened during a chat, selecting a session can either show the saved rollout or resume it --- codex-cli/> | 0 codex-cli/src/cli.tsx | 48 ++++++- .../components/chat/terminal-chat-input.tsx | 10 ++ .../src/components/chat/terminal-chat.tsx | 36 +++++ codex-cli/src/components/sessions-overlay.tsx | 130 ++++++++++++++++++ codex-cli/src/utils/slash-commands.ts | 1 + codex-cli/tests/clear-command.test.tsx | 1 + codex-cli/tests/slash-commands.test.ts | 1 + .../terminal-chat-input-compact.test.tsx | 1 + ...l-chat-input-file-tag-suggestions.test.tsx | 1 + .../terminal-chat-input-multiline.test.tsx | 2 + 11 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 codex-cli/> create mode 100644 codex-cli/src/components/sessions-overlay.tsx diff --git a/codex-cli/> b/codex-cli/> new file mode 100644 index 00000000..e69de29b diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index c009bb8a..d4966f0f 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -14,6 +14,7 @@ import type { ReasoningEffort } from "openai/resources.mjs"; import App from "./app"; import { runSinglePass } from "./cli-singlepass"; +import SessionsOverlay from "./components/sessions-overlay.js"; import { AgentLoop } from "./utils/agent/agent-loop"; import { ReviewDecision } from "./utils/agent/review"; import { AutoApprovalMode } from "./utils/auto-approval-mode"; @@ -60,6 +61,7 @@ const cli = meow( -p, --provider Provider to use for completions (default: openai) -i, --image Path(s) to image files to include as input -v, --view Inspect a previously saved rollout instead of starting a session + --history Browse previous sessions -q, --quiet Non-interactive mode that only prints the assistant's final output -c, --config Open the instructions file in your editor -w, --writable-root Writable folder for sandbox in full-auto mode (can be specified multiple times) @@ -104,6 +106,7 @@ const cli = meow( help: { type: "boolean", aliases: ["h"] }, version: { type: "boolean", description: "Print version and exit" }, view: { type: "string" }, + history: { type: "boolean", description: "Browse previous sessions" }, model: { type: "string", aliases: ["m"] }, provider: { type: "string", aliases: ["p"] }, image: { type: "string", isMultiple: true, aliases: ["i"] }, @@ -261,7 +264,10 @@ let config = loadConfig(undefined, undefined, { isFullContext: fullContextMode, }); -const prompt = cli.input[0]; +// `prompt` can be updated later when the user resumes a previous session +// via the `--history` flag. Therefore it must be declared with `let` rather +// than `const`. +let prompt = cli.input[0]; const model = cli.flags.model ?? config.model; const imagePaths = cli.flags.image; const provider = cli.flags.provider ?? config.provider ?? "openai"; @@ -355,6 +361,46 @@ if ( let rollout: AppRollout | undefined; +// For --history, show session selector and optionally update prompt or rollout. +if (cli.flags.history) { + const result: { path: string; mode: "view" | "resume" } | null = + await new Promise((resolve) => { + const instance = render( + React.createElement(SessionsOverlay, { + onView: (p: string) => { + instance.unmount(); + resolve({ path: p, mode: "view" }); + }, + onResume: (p: string) => { + instance.unmount(); + resolve({ path: p, mode: "resume" }); + }, + onExit: () => { + instance.unmount(); + resolve(null); + }, + }), + ); + }); + + if (!result) { + process.exit(0); + } + + if (result.mode === "view") { + try { + const content = fs.readFileSync(result.path, "utf-8"); + rollout = JSON.parse(content) as AppRollout; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error reading session file:", error); + process.exit(1); + } + } else { + prompt = `Resume this session: ${result.path}`; + } +} + // For --view, optionally load an existing rollout from disk, display it and exit. if (cli.flags.view) { const viewPath = cli.flags.view; diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index e22ec82e..c8c5bf82 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -54,6 +54,7 @@ export default function TerminalChatInput({ openApprovalOverlay, openHelpOverlay, openDiffOverlay, + openSessionsOverlay, onCompact, interruptAgent, active, @@ -77,6 +78,7 @@ export default function TerminalChatInput({ openApprovalOverlay: () => void; openHelpOverlay: () => void; openDiffOverlay: () => void; + openSessionsOverlay: () => void; onCompact: () => void; interruptAgent: () => void; active: boolean; @@ -280,6 +282,9 @@ export default function TerminalChatInput({ case "/history": openOverlay(); break; + case "/sessions": + openSessionsOverlay(); + break; case "/help": openHelpOverlay(); break; @@ -484,6 +489,10 @@ export default function TerminalChatInput({ setInput(""); openOverlay(); return; + } else if (inputValue === "/sessions") { + setInput(""); + openSessionsOverlay(); + return; } else if (inputValue === "/help") { setInput(""); openHelpOverlay(); @@ -728,6 +737,7 @@ export default function TerminalChatInput({ openModelOverlay, openHelpOverlay, openDiffOverlay, + openSessionsOverlay, history, onCompact, skipNextSubmit, diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx index 8eefae8c..d41a9499 100644 --- a/codex-cli/src/components/chat/terminal-chat.tsx +++ b/codex-cli/src/components/chat/terminal-chat.tsx @@ -1,3 +1,4 @@ +import type { AppRollout } from "../../app.js"; import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js"; import type { CommandConfirmation } from "../../utils/agent/agent-loop.js"; import type { AppConfig } from "../../utils/config.js"; @@ -5,6 +6,7 @@ import type { ColorName } from "chalk"; import type { ResponseItem } from "openai/resources/responses/responses.mjs"; import TerminalChatInput from "./terminal-chat-input.js"; +import TerminalChatPastRollout from "./terminal-chat-past-rollout.js"; import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-command.js"; import TerminalMessageHistory from "./terminal-message-history.js"; import { formatCommandForDisplay } from "../../format-command.js"; @@ -32,7 +34,9 @@ import DiffOverlay from "../diff-overlay.js"; import HelpOverlay from "../help-overlay.js"; import HistoryOverlay from "../history-overlay.js"; import ModelOverlay from "../model-overlay.js"; +import SessionsOverlay from "../sessions-overlay.js"; import chalk from "chalk"; +import fs from "fs/promises"; import { Box, Text } from "ink"; import { spawn } from "node:child_process"; import React, { useEffect, useMemo, useRef, useState } from "react"; @@ -41,6 +45,7 @@ import { inspect } from "util"; export type OverlayModeType = | "none" | "history" + | "sessions" | "model" | "approval" | "help" @@ -191,6 +196,7 @@ export default function TerminalChat({ submitConfirmation, } = useConfirmation(); const [overlayMode, setOverlayMode] = useState("none"); + const [viewRollout, setViewRollout] = useState(null); // Store the diff text when opening the diff overlay so the view isn’t // recomputed on every re‑render while it is open. @@ -454,6 +460,16 @@ export default function TerminalChat({ [items, model], ); + if (viewRollout) { + return ( + + ); + } + return ( @@ -509,6 +525,7 @@ export default function TerminalChat({ openModelOverlay={() => setOverlayMode("model")} openApprovalOverlay={() => setOverlayMode("approval")} openHelpOverlay={() => setOverlayMode("help")} + openSessionsOverlay={() => setOverlayMode("sessions")} openDiffOverlay={() => { const { isGitRepo, diff } = getGitDiff(); let text: string; @@ -568,6 +585,25 @@ export default function TerminalChat({ {overlayMode === "history" && ( setOverlayMode("none")} /> )} + {overlayMode === "sessions" && ( + { + try { + const txt = await fs.readFile(p, "utf-8"); + const data = JSON.parse(txt) as AppRollout; + setViewRollout(data); + setOverlayMode("none"); + } catch { + setOverlayMode("none"); + } + }} + onResume={(p) => { + setOverlayMode("none"); + setInitialPrompt(`Resume this session: ${p}`); + }} + onExit={() => setOverlayMode("none")} + /> + )} {overlayMode === "model" && ( > { + try { + const entries = await fs.readdir(SESSIONS_ROOT); + const sessions: Array = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) { + continue; + } + const filePath = path.join(SESSIONS_ROOT, entry); + try { + // eslint-disable-next-line no-await-in-loop + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content) as { + session?: { timestamp?: string }; + items?: Array<{ + type: string; + role: string; + content: Array<{ text: string }>; + }>; + }; + const items = Array.isArray(data.items) ? data.items : []; + const firstUser = items.find( + (i) => i?.type === "message" && i.role === "user", + ); + const firstText = + firstUser?.content?.[0]?.text?.replace(/\n/g, " ").slice(0, 16) ?? ""; + const userMessages = items.filter( + (i) => i?.type === "message" && i.role === "user", + ).length; + const toolCalls = items.filter( + (i) => i?.type === "function_call", + ).length; + sessions.push({ + path: filePath, + timestamp: data.session?.timestamp || "", + userMessages, + toolCalls, + firstMessage: firstText, + }); + } catch { + /* ignore invalid session */ + } + } + sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return sessions; + } catch { + return []; + } +} + +type Props = { + onView: (sessionPath: string) => void; + onResume: (sessionPath: string) => void; + onExit: () => void; +}; + +export default function SessionsOverlay({ + onView, + onResume, + onExit, +}: Props): JSX.Element { + const [items, setItems] = useState>([]); + const [mode, setMode] = useState<"view" | "resume">("view"); + + useEffect(() => { + (async () => { + const sessions = await loadSessions(); + const formatted = sessions.map((s) => { + const ts = s.timestamp + ? new Date(s.timestamp).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }) + : ""; + const first = s.firstMessage?.slice(0, 50); + const label = `${ts} · ${s.userMessages} msgs/${s.toolCalls} tools · ${first}`; + return { label, value: s.path } as TypeaheadItem; + }); + setItems(formatted); + })(); + }, []); + + useInput((_input, key) => { + if (key.tab) { + setMode((m) => (m === "view" ? "resume" : "view")); + } + }); + + return ( + + + {mode === "view" ? "press enter to view" : "press enter to resume"} + + tab to toggle mode · esc to cancel + + } + initialItems={items} + onSelect={(value) => { + if (mode === "view") { + onView(value); + } else { + onResume(value); + } + }} + onExit={onExit} + /> + ); +} diff --git a/codex-cli/src/utils/slash-commands.ts b/codex-cli/src/utils/slash-commands.ts index 4ccc3a9f..c139c04a 100644 --- a/codex-cli/src/utils/slash-commands.ts +++ b/codex-cli/src/utils/slash-commands.ts @@ -20,6 +20,7 @@ export const SLASH_COMMANDS: Array = [ "Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]", }, { command: "/history", description: "Open command history" }, + { command: "/sessions", description: "Browse previous sessions" }, { command: "/help", description: "Show list of commands" }, { command: "/model", description: "Open model selection panel" }, { command: "/approval", description: "Open approval mode selection panel" }, diff --git a/codex-cli/tests/clear-command.test.tsx b/codex-cli/tests/clear-command.test.tsx index c2d48044..09180a8f 100644 --- a/codex-cli/tests/clear-command.test.tsx +++ b/codex-cli/tests/clear-command.test.tsx @@ -55,6 +55,7 @@ describe("/clear command", () => { openApprovalOverlay: () => {}, openHelpOverlay: () => {}, openDiffOverlay: () => {}, + openSessionsOverlay: () => {}, onCompact: () => {}, interruptAgent: () => {}, active: true, diff --git a/codex-cli/tests/slash-commands.test.ts b/codex-cli/tests/slash-commands.test.ts index b10a484f..b4374e06 100644 --- a/codex-cli/tests/slash-commands.test.ts +++ b/codex-cli/tests/slash-commands.test.ts @@ -6,6 +6,7 @@ test("SLASH_COMMANDS includes expected commands", () => { expect(commands).toContain("/clear"); expect(commands).toContain("/compact"); expect(commands).toContain("/history"); + expect(commands).toContain("/sessions"); expect(commands).toContain("/help"); expect(commands).toContain("/model"); expect(commands).toContain("/approval"); diff --git a/codex-cli/tests/terminal-chat-input-compact.test.tsx b/codex-cli/tests/terminal-chat-input-compact.test.tsx index 2120aab4..ced707cf 100644 --- a/codex-cli/tests/terminal-chat-input-compact.test.tsx +++ b/codex-cli/tests/terminal-chat-input-compact.test.tsx @@ -21,6 +21,7 @@ describe("TerminalChatInput compact command", () => { openModelOverlay: () => {}, openApprovalOverlay: () => {}, openHelpOverlay: () => {}, + openSessionsOverlay: () => {}, onCompact: () => {}, interruptAgent: () => {}, active: true, diff --git a/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx index 74dbd8c4..ac399c85 100644 --- a/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx +++ b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx @@ -76,6 +76,7 @@ describe("TerminalChatInput file tag suggestions", () => { openModelOverlay: vi.fn(), openApprovalOverlay: vi.fn(), openHelpOverlay: vi.fn(), + openSessionsOverlay: vi.fn(), onCompact: vi.fn(), interruptAgent: vi.fn(), active: true, diff --git a/codex-cli/tests/terminal-chat-input-multiline.test.tsx b/codex-cli/tests/terminal-chat-input-multiline.test.tsx index ff95e5e8..6d0f4336 100644 --- a/codex-cli/tests/terminal-chat-input-multiline.test.tsx +++ b/codex-cli/tests/terminal-chat-input-multiline.test.tsx @@ -42,6 +42,7 @@ describe("TerminalChatInput multiline functionality", () => { openModelOverlay: () => {}, openApprovalOverlay: () => {}, openHelpOverlay: () => {}, + openSessionsOverlay: () => {}, onCompact: () => {}, interruptAgent: () => {}, active: true, @@ -93,6 +94,7 @@ describe("TerminalChatInput multiline functionality", () => { openModelOverlay: () => {}, openApprovalOverlay: () => {}, openHelpOverlay: () => {}, + openSessionsOverlay: () => {}, onCompact: () => {}, interruptAgent: () => {}, active: true,