feat: /diff command to view git diff (#426)
Adds `/diff` command to view git diff
This commit is contained in:
@@ -42,6 +42,7 @@ export default function TerminalChatInput({
|
|||||||
openModelOverlay,
|
openModelOverlay,
|
||||||
openApprovalOverlay,
|
openApprovalOverlay,
|
||||||
openHelpOverlay,
|
openHelpOverlay,
|
||||||
|
openDiffOverlay,
|
||||||
onCompact,
|
onCompact,
|
||||||
interruptAgent,
|
interruptAgent,
|
||||||
active,
|
active,
|
||||||
@@ -64,6 +65,7 @@ export default function TerminalChatInput({
|
|||||||
openModelOverlay: () => void;
|
openModelOverlay: () => void;
|
||||||
openApprovalOverlay: () => void;
|
openApprovalOverlay: () => void;
|
||||||
openHelpOverlay: () => void;
|
openHelpOverlay: () => void;
|
||||||
|
openDiffOverlay: () => void;
|
||||||
onCompact: () => void;
|
onCompact: () => void;
|
||||||
interruptAgent: () => void;
|
interruptAgent: () => void;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -270,6 +272,12 @@ export default function TerminalChatInput({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inputValue === "/diff") {
|
||||||
|
setInput("");
|
||||||
|
openDiffOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (inputValue === "/compact") {
|
if (inputValue === "/compact") {
|
||||||
setInput("");
|
setInput("");
|
||||||
onCompact();
|
onCompact();
|
||||||
@@ -494,6 +502,7 @@ export default function TerminalChatInput({
|
|||||||
openApprovalOverlay,
|
openApprovalOverlay,
|
||||||
openModelOverlay,
|
openModelOverlay,
|
||||||
openHelpOverlay,
|
openHelpOverlay,
|
||||||
|
openDiffOverlay,
|
||||||
history,
|
history,
|
||||||
onCompact,
|
onCompact,
|
||||||
skipNextSubmit,
|
skipNextSubmit,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function TerminalChatInput({
|
|||||||
openModelOverlay,
|
openModelOverlay,
|
||||||
openApprovalOverlay,
|
openApprovalOverlay,
|
||||||
openHelpOverlay,
|
openHelpOverlay,
|
||||||
|
openDiffOverlay,
|
||||||
interruptAgent,
|
interruptAgent,
|
||||||
active,
|
active,
|
||||||
thinkingSeconds,
|
thinkingSeconds,
|
||||||
@@ -72,6 +73,7 @@ export default function TerminalChatInput({
|
|||||||
openModelOverlay: () => void;
|
openModelOverlay: () => void;
|
||||||
openApprovalOverlay: () => void;
|
openApprovalOverlay: () => void;
|
||||||
openHelpOverlay: () => void;
|
openHelpOverlay: () => void;
|
||||||
|
openDiffOverlay: () => void;
|
||||||
interruptAgent: () => void;
|
interruptAgent: () => void;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
thinkingSeconds: number;
|
thinkingSeconds: number;
|
||||||
@@ -230,6 +232,12 @@ export default function TerminalChatInput({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inputValue === "/diff") {
|
||||||
|
setInput("");
|
||||||
|
openDiffOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (inputValue.startsWith("/model")) {
|
if (inputValue.startsWith("/model")) {
|
||||||
setInput("");
|
setInput("");
|
||||||
openModelOverlay();
|
openModelOverlay();
|
||||||
@@ -337,6 +345,7 @@ export default function TerminalChatInput({
|
|||||||
openApprovalOverlay,
|
openApprovalOverlay,
|
||||||
openModelOverlay,
|
openModelOverlay,
|
||||||
openHelpOverlay,
|
openHelpOverlay,
|
||||||
|
openDiffOverlay,
|
||||||
history, // Add history to the dependency array
|
history, // Add history to the dependency array
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ import { isLoggingEnabled, log } from "../../utils/agent/log.js";
|
|||||||
import { ReviewDecision } from "../../utils/agent/review.js";
|
import { ReviewDecision } from "../../utils/agent/review.js";
|
||||||
import { generateCompactSummary } from "../../utils/compact-summary.js";
|
import { generateCompactSummary } from "../../utils/compact-summary.js";
|
||||||
import { OPENAI_BASE_URL } from "../../utils/config.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 { createInputItem } from "../../utils/input-utils.js";
|
||||||
import { getAvailableModels } from "../../utils/model-utils.js";
|
import { getAvailableModels } from "../../utils/model-utils.js";
|
||||||
import { CLI_VERSION } from "../../utils/session.js";
|
import { CLI_VERSION } from "../../utils/session.js";
|
||||||
import { shortCwd } from "../../utils/short-path.js";
|
import { shortCwd } from "../../utils/short-path.js";
|
||||||
import { saveRollout } from "../../utils/storage/save-rollout.js";
|
import { saveRollout } from "../../utils/storage/save-rollout.js";
|
||||||
import ApprovalModeOverlay from "../approval-mode-overlay.js";
|
import ApprovalModeOverlay from "../approval-mode-overlay.js";
|
||||||
|
import DiffOverlay from "../diff-overlay.js";
|
||||||
import HelpOverlay from "../help-overlay.js";
|
import HelpOverlay from "../help-overlay.js";
|
||||||
import HistoryOverlay from "../history-overlay.js";
|
import HistoryOverlay from "../history-overlay.js";
|
||||||
import ModelOverlay from "../model-overlay.js";
|
import ModelOverlay from "../model-overlay.js";
|
||||||
@@ -180,9 +183,16 @@ export default function TerminalChat({
|
|||||||
submitConfirmation,
|
submitConfirmation,
|
||||||
} = useConfirmation();
|
} = useConfirmation();
|
||||||
const [overlayMode, setOverlayMode] = useState<
|
const [overlayMode, setOverlayMode] = useState<
|
||||||
"none" | "history" | "model" | "approval" | "help"
|
"none" | "history" | "model" | "approval" | "help" | "diff"
|
||||||
>("none");
|
>("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<string>("");
|
||||||
|
|
||||||
const [initialPrompt, setInitialPrompt] = useState(_initialPrompt);
|
const [initialPrompt, setInitialPrompt] = useState(_initialPrompt);
|
||||||
const [initialImagePaths, setInitialImagePaths] =
|
const [initialImagePaths, setInitialImagePaths] =
|
||||||
useState(_initialImagePaths);
|
useState(_initialImagePaths);
|
||||||
@@ -497,6 +507,26 @@ export default function TerminalChat({
|
|||||||
openModelOverlay={() => setOverlayMode("model")}
|
openModelOverlay={() => setOverlayMode("model")}
|
||||||
openApprovalOverlay={() => setOverlayMode("approval")}
|
openApprovalOverlay={() => setOverlayMode("approval")}
|
||||||
openHelpOverlay={() => setOverlayMode("help")}
|
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}
|
onCompact={handleCompact}
|
||||||
active={overlayMode === "none"}
|
active={overlayMode === "none"}
|
||||||
interruptAgent={() => {
|
interruptAgent={() => {
|
||||||
@@ -622,6 +652,13 @@ export default function TerminalChat({
|
|||||||
{overlayMode === "help" && (
|
{overlayMode === "help" && (
|
||||||
<HelpOverlay onExit={() => setOverlayMode("none")} />
|
<HelpOverlay onExit={() => setOverlayMode("none")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{overlayMode === "diff" && (
|
||||||
|
<DiffOverlay
|
||||||
|
diffText={diffText}
|
||||||
|
onExit={() => setOverlayMode("none")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
93
codex-cli/src/components/diff-overlay.tsx
Normal file
93
codex-cli/src/components/diff-overlay.tsx
Normal file
@@ -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 (
|
||||||
|
<Text key={idx} color={color} wrap="truncate-end">
|
||||||
|
{line === "" ? " " : line}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor="gray"
|
||||||
|
width={Math.min(120, process.stdout.columns || 120)}
|
||||||
|
>
|
||||||
|
<Box paddingX={1}>
|
||||||
|
<Text bold>Working tree diff ({lines.length} lines)</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
{visible.map((line, idx) => {
|
||||||
|
return renderLine(line, firstVisible + idx);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box paddingX={1}>
|
||||||
|
<Text dimColor>esc Close ↑↓ Scroll PgUp/PgDn g/G First/Last</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ export default function HelpOverlay({
|
|||||||
<Text>
|
<Text>
|
||||||
<Text color="cyan">/bug</Text> – file a bug report with session log
|
<Text color="cyan">/bug</Text> – file a bug report with session log
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color="cyan">/diff</Text> – view working tree git diff
|
||||||
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
<Text color="cyan">/compact</Text> – condense context into a summary
|
<Text color="cyan">/compact</Text> – condense context into a summary
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
36
codex-cli/src/utils/extract-applied-patches.ts
Normal file
36
codex-cli/src/utils/extract-applied-patches.ts
Normal file
@@ -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<ResponseItem>): string {
|
||||||
|
const patches: Array<string> = [];
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
29
codex-cli/src/utils/get-diff.ts
Normal file
29
codex-cli/src/utils/get-diff.ts
Normal file
@@ -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: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,4 +24,9 @@ export const SLASH_COMMANDS: Array<SlashCommand> = [
|
|||||||
{ command: "/model", description: "Open model selection panel" },
|
{ command: "/model", description: "Open model selection panel" },
|
||||||
{ command: "/approval", description: "Open approval mode selection panel" },
|
{ command: "/approval", description: "Open approval mode selection panel" },
|
||||||
{ command: "/bug", description: "Generate a prefilled GitHub bug report" },
|
{ 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)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test("SLASH_COMMANDS includes expected commands", () => {
|
|||||||
expect(commands).toContain("/model");
|
expect(commands).toContain("/model");
|
||||||
expect(commands).toContain("/approval");
|
expect(commands).toContain("/approval");
|
||||||
expect(commands).toContain("/clearhistory");
|
expect(commands).toContain("/clearhistory");
|
||||||
|
expect(commands).toContain("/diff");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filters slash commands by prefix", () => {
|
test("filters slash commands by prefix", () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe("TerminalChatInput compact command", () => {
|
|||||||
setItems: () => {},
|
setItems: () => {},
|
||||||
contextLeftPercent: 10,
|
contextLeftPercent: 10,
|
||||||
openOverlay: () => {},
|
openOverlay: () => {},
|
||||||
|
openDiffOverlay: () => {},
|
||||||
openModelOverlay: () => {},
|
openModelOverlay: () => {},
|
||||||
openApprovalOverlay: () => {},
|
openApprovalOverlay: () => {},
|
||||||
openHelpOverlay: () => {},
|
openHelpOverlay: () => {},
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
"*.md": "prettier --write",
|
"*.md": "prettier --write",
|
||||||
".github/workflows/*.yml": "prettier --write",
|
".github/workflows/*.yml": "prettier --write",
|
||||||
"**/*.{js,ts,tsx}": [
|
"**/*.{js,ts,tsx}": [
|
||||||
"pnpm --filter @openai/codex run lint",
|
"cd codex-cli && pnpm run lint",
|
||||||
"pnpm --filter @openai/codex run typecheck"
|
"cd codex-cli && pnpm run typecheck"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.8.1"
|
"packageManager": "pnpm@10.8.1"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'codex-cli'
|
- codex-cli
|
||||||
- 'docs'
|
- docs
|
||||||
# For future packages
|
- packages/*
|
||||||
- 'packages/*'
|
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
|||||||
Reference in New Issue
Block a user