diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 59265221..e1bcbf9e 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -42,6 +42,7 @@ export default function TerminalChatInput({ openModelOverlay, openApprovalOverlay, openHelpOverlay, + openDiffOverlay, onCompact, interruptAgent, active, @@ -64,6 +65,7 @@ export default function TerminalChatInput({ openModelOverlay: () => void; openApprovalOverlay: () => void; openHelpOverlay: () => void; + openDiffOverlay: () => void; onCompact: () => void; interruptAgent: () => void; active: boolean; @@ -270,6 +272,12 @@ export default function TerminalChatInput({ return; } + if (inputValue === "/diff") { + setInput(""); + openDiffOverlay(); + return; + } + if (inputValue === "/compact") { setInput(""); onCompact(); @@ -494,6 +502,7 @@ export default function TerminalChatInput({ openApprovalOverlay, openModelOverlay, openHelpOverlay, + openDiffOverlay, history, onCompact, skipNextSubmit, 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 9ceb4bbc..57acba36 100644 --- a/codex-cli/src/components/chat/terminal-chat-new-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-new-input.tsx @@ -52,6 +52,7 @@ export default function TerminalChatInput({ openModelOverlay, openApprovalOverlay, openHelpOverlay, + openDiffOverlay, interruptAgent, active, thinkingSeconds, @@ -72,6 +73,7 @@ export default function TerminalChatInput({ openModelOverlay: () => void; openApprovalOverlay: () => void; openHelpOverlay: () => void; + openDiffOverlay: () => void; interruptAgent: () => void; active: boolean; thinkingSeconds: number; @@ -230,6 +232,12 @@ export default function TerminalChatInput({ return; } + if (inputValue === "/diff") { + setInput(""); + openDiffOverlay(); + return; + } + if (inputValue.startsWith("/model")) { setInput(""); openModelOverlay(); @@ -337,6 +345,7 @@ export default function TerminalChatInput({ openApprovalOverlay, openModelOverlay, openHelpOverlay, + openDiffOverlay, history, // Add history to the dependency array ], ); diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx index e341cdfb..26112f14 100644 --- a/codex-cli/src/components/chat/terminal-chat.tsx +++ b/codex-cli/src/components/chat/terminal-chat.tsx @@ -19,12 +19,15 @@ import { isLoggingEnabled, log } from "../../utils/agent/log.js"; import { ReviewDecision } from "../../utils/agent/review.js"; import { generateCompactSummary } from "../../utils/compact-summary.js"; import { OPENAI_BASE_URL } from "../../utils/config.js"; +import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/extract-applied-patches.js"; +import { getGitDiff } from "../../utils/get-diff.js"; import { createInputItem } from "../../utils/input-utils.js"; import { getAvailableModels } from "../../utils/model-utils.js"; import { CLI_VERSION } from "../../utils/session.js"; import { shortCwd } from "../../utils/short-path.js"; import { saveRollout } from "../../utils/storage/save-rollout.js"; import ApprovalModeOverlay from "../approval-mode-overlay.js"; +import DiffOverlay from "../diff-overlay.js"; import HelpOverlay from "../help-overlay.js"; import HistoryOverlay from "../history-overlay.js"; import ModelOverlay from "../model-overlay.js"; @@ -180,9 +183,16 @@ export default function TerminalChat({ submitConfirmation, } = useConfirmation(); const [overlayMode, setOverlayMode] = useState< - "none" | "history" | "model" | "approval" | "help" + "none" | "history" | "model" | "approval" | "help" | "diff" >("none"); + // Store the diff text when opening the diff overlay so the view isn’t + // recomputed on every re‑render while it is open. + // diffText is passed down to the DiffOverlay component. The setter is + // currently unused but retained for potential future updates. Prefix with + // an underscore so eslint ignores the unused variable. + const [diffText, _setDiffText] = useState(""); + const [initialPrompt, setInitialPrompt] = useState(_initialPrompt); const [initialImagePaths, setInitialImagePaths] = useState(_initialImagePaths); @@ -497,6 +507,26 @@ export default function TerminalChat({ openModelOverlay={() => setOverlayMode("model")} openApprovalOverlay={() => setOverlayMode("approval")} openHelpOverlay={() => setOverlayMode("help")} + openDiffOverlay={() => { + const { isGitRepo, diff } = getGitDiff(); + let text: string; + if (isGitRepo) { + text = diff; + } else { + text = "`/diff` — _not inside a git repository_"; + } + setItems((prev) => [ + ...prev, + { + id: `diff-${Date.now()}`, + type: "message", + role: "system", + content: [{ type: "input_text", text }], + }, + ]); + // Ensure no overlay is shown. + setOverlayMode("none"); + }} onCompact={handleCompact} active={overlayMode === "none"} interruptAgent={() => { @@ -622,6 +652,13 @@ export default function TerminalChat({ {overlayMode === "help" && ( setOverlayMode("none")} /> )} + + {overlayMode === "diff" && ( + setOverlayMode("none")} + /> + )} ); diff --git a/codex-cli/src/components/diff-overlay.tsx b/codex-cli/src/components/diff-overlay.tsx new file mode 100644 index 00000000..8de85b87 --- /dev/null +++ b/codex-cli/src/components/diff-overlay.tsx @@ -0,0 +1,93 @@ +import { Box, Text, useInput } from "ink"; +import React, { useState } from "react"; + +/** + * Simple scrollable view for displaying a diff. + * The component is intentionally lightweight and mirrors the UX of + * HistoryOverlay: Up/Down or j/k to scroll, PgUp/PgDn for paging and Esc to + * close. The caller is responsible for computing the diff text. + */ +export default function DiffOverlay({ + diffText, + onExit, +}: { + diffText: string; + onExit: () => void; +}): JSX.Element { + const lines = diffText.length > 0 ? diffText.split("\n") : ["(no changes)"]; + + const [cursor, setCursor] = useState(0); + + // Determine how many rows we can display – similar to HistoryOverlay. + const rows = process.stdout.rows || 24; + const headerRows = 2; + const footerRows = 1; + const maxVisible = Math.max(4, rows - headerRows - footerRows); + + useInput((input, key) => { + if (key.escape || input === "q") { + onExit(); + return; + } + + if (key.downArrow || input === "j") { + setCursor((c) => Math.min(lines.length - 1, c + 1)); + } else if (key.upArrow || input === "k") { + setCursor((c) => Math.max(0, c - 1)); + } else if (key.pageDown) { + setCursor((c) => Math.min(lines.length - 1, c + maxVisible)); + } else if (key.pageUp) { + setCursor((c) => Math.max(0, c - maxVisible)); + } else if (input === "g") { + setCursor(0); + } else if (input === "G") { + setCursor(lines.length - 1); + } + }); + + const firstVisible = Math.min( + Math.max(0, cursor - Math.floor(maxVisible / 2)), + Math.max(0, lines.length - maxVisible), + ); + const visible = lines.slice(firstVisible, firstVisible + maxVisible); + + // Very small helper to colorize diff lines in a basic way. + function renderLine(line: string, idx: number): JSX.Element { + let color: "green" | "red" | "cyan" | undefined = undefined; + if (line.startsWith("+")) { + color = "green"; + } else if (line.startsWith("-")) { + color = "red"; + } else if (line.startsWith("@@") || line.startsWith("diff --git")) { + color = "cyan"; + } + return ( + + {line === "" ? " " : line} + + ); + } + + return ( + + + Working tree diff ({lines.length} lines) + + + + {visible.map((line, idx) => { + return renderLine(line, firstVisible + idx); + })} + + + + esc Close ↑↓ Scroll PgUp/PgDn g/G First/Last + + + ); +} diff --git a/codex-cli/src/components/help-overlay.tsx b/codex-cli/src/components/help-overlay.tsx index 132add83..6eeffb9e 100644 --- a/codex-cli/src/components/help-overlay.tsx +++ b/codex-cli/src/components/help-overlay.tsx @@ -55,6 +55,9 @@ export default function HelpOverlay({ /bug – file a bug report with session log + + /diff – view working tree git diff + /compact – condense context into a summary diff --git a/codex-cli/src/utils/extract-applied-patches.ts b/codex-cli/src/utils/extract-applied-patches.ts new file mode 100644 index 00000000..3e9bc104 --- /dev/null +++ b/codex-cli/src/utils/extract-applied-patches.ts @@ -0,0 +1,36 @@ +import type { ResponseItem } from "openai/resources/responses/responses.mjs"; + +/** + * Extracts the patch texts of all `apply_patch` tool calls from the given + * message history. Returns an empty string when none are found. + */ +export function extractAppliedPatches(items: Array): string { + const patches: Array = []; + + for (const item of items) { + if (item.type !== "function_call") { + continue; + } + + const { name: toolName, arguments: argsString } = item as unknown as { + name: unknown; + arguments: unknown; + }; + + if (toolName !== "apply_patch" || typeof argsString !== "string") { + continue; + } + + try { + const args = JSON.parse(argsString) as { patch?: string }; + if (typeof args.patch === "string" && args.patch.length > 0) { + patches.push(args.patch.trim()); + } + } catch { + // Ignore malformed JSON – we never want to crash the overlay. + continue; + } + } + + return patches.join("\n\n"); +} diff --git a/codex-cli/src/utils/get-diff.ts b/codex-cli/src/utils/get-diff.ts new file mode 100644 index 00000000..348ee42c --- /dev/null +++ b/codex-cli/src/utils/get-diff.ts @@ -0,0 +1,29 @@ +import { execSync } from "node:child_process"; + +/** + * Returns the current Git diff for the working directory. If the current + * working directory is not inside a Git repository, `isGitRepo` will be + * false and `diff` will be an empty string. + */ +export function getGitDiff(): { + isGitRepo: boolean; + diff: string; +} { + try { + // First check whether we are inside a git repository. `rev‑parse` exits + // with a non‑zero status code if not. + execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); + + // If the above call didn’t throw, we are inside a git repo. Retrieve the + // diff including color codes so that the overlay can render them. + const output = execSync("git diff --color", { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now + }); + + return { isGitRepo: true, diff: output }; + } catch { + // Either git is not installed or we’re not inside a repository. + return { isGitRepo: false, diff: "" }; + } +} diff --git a/codex-cli/src/utils/slash-commands.ts b/codex-cli/src/utils/slash-commands.ts index 720941a9..b276c491 100644 --- a/codex-cli/src/utils/slash-commands.ts +++ b/codex-cli/src/utils/slash-commands.ts @@ -24,4 +24,9 @@ export const SLASH_COMMANDS: Array = [ { command: "/model", description: "Open model selection panel" }, { command: "/approval", description: "Open approval mode selection panel" }, { command: "/bug", description: "Generate a prefilled GitHub bug report" }, + { + command: "/diff", + description: + "Show git diff of the working directory (or applied patches if not in git)", + }, ]; diff --git a/codex-cli/tests/slash-commands.test.ts b/codex-cli/tests/slash-commands.test.ts index 4864aa26..b10a484f 100644 --- a/codex-cli/tests/slash-commands.test.ts +++ b/codex-cli/tests/slash-commands.test.ts @@ -10,6 +10,7 @@ test("SLASH_COMMANDS includes expected commands", () => { expect(commands).toContain("/model"); expect(commands).toContain("/approval"); expect(commands).toContain("/clearhistory"); + expect(commands).toContain("/diff"); }); test("filters slash commands by prefix", () => { diff --git a/codex-cli/tests/terminal-chat-input-compact.test.tsx b/codex-cli/tests/terminal-chat-input-compact.test.tsx index d93a07ab..2120aab4 100644 --- a/codex-cli/tests/terminal-chat-input-compact.test.tsx +++ b/codex-cli/tests/terminal-chat-input-compact.test.tsx @@ -17,6 +17,7 @@ describe("TerminalChatInput compact command", () => { setItems: () => {}, contextLeftPercent: 10, openOverlay: () => {}, + openDiffOverlay: () => {}, openModelOverlay: () => {}, openApprovalOverlay: () => {}, openHelpOverlay: () => {}, diff --git a/package.json b/package.json index a54db4ad..215616be 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "*.md": "prettier --write", ".github/workflows/*.yml": "prettier --write", "**/*.{js,ts,tsx}": [ - "pnpm --filter @openai/codex run lint", - "pnpm --filter @openai/codex run typecheck" + "cd codex-cli && pnpm run lint", + "cd codex-cli && pnpm run typecheck" ] }, "packageManager": "pnpm@10.8.1" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6aaf86b6..d3ac8560 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,7 @@ packages: - - 'codex-cli' - - 'docs' - # For future packages - - 'packages/*' + - codex-cli + - docs + - packages/* + +ignoredBuiltDependencies: + - esbuild