add: session history viewer (#912)
- 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
This commit is contained in:
0
codex-cli/>
Normal file
0
codex-cli/>
Normal file
@@ -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> Provider to use for completions (default: openai)
|
||||
-i, --image <path> Path(s) to image files to include as input
|
||||
-v, --view <rollout> 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 <path> 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OverlayModeType>("none");
|
||||
const [viewRollout, setViewRollout] = useState<AppRollout | null>(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 (
|
||||
<TerminalChatPastRollout
|
||||
fileOpener={config.fileOpener}
|
||||
session={viewRollout.session}
|
||||
items={viewRollout.items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
@@ -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" && (
|
||||
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
{overlayMode === "sessions" && (
|
||||
<SessionsOverlay
|
||||
onView={async (p) => {
|
||||
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" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
|
||||
130
codex-cli/src/components/sessions-overlay.tsx
Normal file
130
codex-cli/src/components/sessions-overlay.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { TypeaheadItem } from "./typeahead-overlay.js";
|
||||
|
||||
import TypeaheadOverlay from "./typeahead-overlay.js";
|
||||
import fs from "fs/promises";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const SESSIONS_ROOT = path.join(os.homedir(), ".codex", "sessions");
|
||||
|
||||
export type SessionMeta = {
|
||||
path: string;
|
||||
timestamp: string;
|
||||
userMessages: number;
|
||||
toolCalls: number;
|
||||
firstMessage: string;
|
||||
};
|
||||
|
||||
async function loadSessions(): Promise<Array<SessionMeta>> {
|
||||
try {
|
||||
const entries = await fs.readdir(SESSIONS_ROOT);
|
||||
const sessions: Array<SessionMeta> = [];
|
||||
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<Array<TypeaheadItem>>([]);
|
||||
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 (
|
||||
<TypeaheadOverlay
|
||||
title={mode === "view" ? "View session" : "Resume session"}
|
||||
description={
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{mode === "view" ? "press enter to view" : "press enter to resume"}
|
||||
</Text>
|
||||
<Text dimColor>tab to toggle mode · esc to cancel</Text>
|
||||
</Box>
|
||||
}
|
||||
initialItems={items}
|
||||
onSelect={(value) => {
|
||||
if (mode === "view") {
|
||||
onView(value);
|
||||
} else {
|
||||
onResume(value);
|
||||
}
|
||||
}}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const SLASH_COMMANDS: Array<SlashCommand> = [
|
||||
"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" },
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("/clear command", () => {
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("TerminalChatInput compact command", () => {
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user