Files
codex/codex-cli/src/components/history-overlay.tsx
Luci a9ecb2efce chore: upgrade prettier to v3 (#644)
## Description

This PR addresses the following improvements:

**Unify Prettier Version**: Currently, the Prettier version used in
`/package.json` and `/codex-cli/package.json` are different. In this PR,
we're updating both to use Prettier v3.

- Prettier v3 introduces improved support for JavaScript and TypeScript.
(e.g. the formatting scenario shown in the image below. This is more
aligned with the TypeScript indentation standard).

<img width="1126" alt="image"
src="https://github.com/user-attachments/assets/6e237eb8-4553-4574-b336-ed9561c55370"
/>

**Add Prettier Auto-Formatting in lint-staged**: We've added a step to
automatically run prettier --write on JavaScript and TypeScript files as
part of the lint-staged process, before the ESLint checks.

- This will help ensure that all committed code is properly formatted
according to the project's Prettier configuration.
2025-04-25 07:21:50 -07:00

256 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
import { Box, Text, useInput } from "ink";
import React, { useMemo, useState } from "react";
type Props = {
items: Array<ResponseItem>;
onExit: () => void;
};
type Mode = "commands" | "files";
export default function HistoryOverlay({ items, onExit }: Props): JSX.Element {
const [mode, setMode] = useState<Mode>("commands");
const [cursor, setCursor] = useState(0);
const { commands, files } = useMemo(
() => formatHistoryForDisplay(items),
[items],
);
const list = mode === "commands" ? commands : files;
useInput((input, key) => {
if (key.escape) {
onExit();
return;
}
if (input === "c") {
setMode("commands");
setCursor(0);
return;
}
if (input === "f") {
setMode("files");
setCursor(0);
return;
}
if (key.downArrow || input === "j") {
setCursor((c) => Math.min(list.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(list.length - 1, c + 10));
} else if (key.pageUp) {
setCursor((c) => Math.max(0, c - 10));
} else if (input === "g") {
setCursor(0);
} else if (input === "G") {
setCursor(list.length - 1);
}
});
const rows = process.stdout.rows || 24;
const headerRows = 2;
const footerRows = 1;
const maxVisible = Math.max(4, rows - headerRows - footerRows);
const firstVisible = Math.min(
Math.max(0, cursor - Math.floor(maxVisible / 2)),
Math.max(0, list.length - maxVisible),
);
const visible = list.slice(firstVisible, firstVisible + maxVisible);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
width={100}
>
<Box paddingX={1}>
<Text bold>
{mode === "commands" ? "Commands run" : "Files touched"} (
{list.length})
</Text>
</Box>
<Box flexDirection="column" paddingX={1}>
{visible.map((txt, idx) => {
const absIdx = firstVisible + idx;
const selected = absIdx === cursor;
return (
<Text key={absIdx} color={selected ? "cyan" : undefined}>
{selected ? " " : " "}
{txt}
</Text>
);
})}
</Box>
<Box paddingX={1}>
<Text dimColor>
esc Close Scroll PgUp/PgDn g/G First/Last c Commands f Files
</Text>
</Box>
</Box>
);
}
function formatHistoryForDisplay(items: Array<ResponseItem>): {
commands: Array<string>;
files: Array<string>;
} {
const commands: Array<string> = [];
const filesSet = new Set<string>();
for (const item of items) {
const userPrompt = processUserMessage(item);
if (userPrompt) {
commands.push(userPrompt);
continue;
}
// ------------------------------------------------------------------
// We are interested in tool calls which for the OpenAI client are
// represented as `function_call` response items. Skip everything else.
if (item.type !== "function_call") {
continue;
}
const { name: toolName, arguments: argsString } = item as unknown as {
name: unknown;
arguments: unknown;
};
if (typeof argsString !== "string") {
// Malformed still record the tool name to give users maximal context.
if (typeof toolName === "string" && toolName.length > 0) {
commands.push(toolName);
}
continue;
}
// Besteffort attempt to parse the JSON arguments. We never throw on parse
// failure the history view must be resilient to bad data.
let argsJson: unknown = undefined;
try {
argsJson = JSON.parse(argsString);
} catch {
argsJson = undefined;
}
// 1) Shell / execlike tool calls expose a `cmd` or `command` property
// that is an array of strings. These are rendered as the joined command
// line for familiarity with traditional shells.
const argsObj = argsJson as Record<string, unknown> | undefined;
const cmdArray: Array<string> | undefined = Array.isArray(argsObj?.["cmd"])
? (argsObj!["cmd"] as Array<string>)
: Array.isArray(argsObj?.["command"])
? (argsObj!["command"] as Array<string>)
: undefined;
if (cmdArray && cmdArray.length > 0) {
commands.push(processCommandArray(cmdArray, filesSet));
continue; // We processed this as a command; no need to treat as generic tool call.
}
// 2) Nonexec tool calls we fall back to recording the tool name plus a
// short argument representation to give users an idea of what
// happened.
if (typeof toolName === "string" && toolName.length > 0) {
commands.push(processNonExecTool(toolName, argsJson, filesSet));
}
}
return { commands, files: Array.from(filesSet) };
}
function processUserMessage(item: ResponseItem): string | null {
if (
item.type === "message" &&
(item as unknown as { role?: string }).role === "user"
) {
// TODO: We're ignoring images/files here.
const parts =
(item as unknown as { content?: Array<unknown> }).content ?? [];
const texts: Array<string> = [];
if (Array.isArray(parts)) {
for (const part of parts) {
if (part && typeof part === "object" && "text" in part) {
const t = (part as unknown as { text?: string }).text;
if (typeof t === "string" && t.length > 0) {
texts.push(t);
}
}
}
}
if (texts.length > 0) {
const fullPrompt = texts.join(" ");
// Truncate very long prompts so the history view stays legible.
return fullPrompt.length > 120
? `> ${fullPrompt.slice(0, 117)}`
: `> ${fullPrompt}`;
}
}
return null;
}
function processCommandArray(
cmdArray: Array<string>,
filesSet: Set<string>,
): string {
const cmd = cmdArray.join(" ");
// Heuristic for file paths in command args
for (const part of cmdArray) {
if (!part.startsWith("-") && part.includes("/")) {
filesSet.add(part);
}
}
// Specialcase apply_patch so we can extract the list of modified files
if (cmdArray[0] === "apply_patch" || cmdArray.includes("apply_patch")) {
const patchTextMaybe = cmdArray.find((s) => s.includes("*** Begin Patch"));
if (typeof patchTextMaybe === "string") {
const lines = patchTextMaybe.split("\n");
for (const line of lines) {
const m = line.match(/^[-+]{3} [ab]\/(.+)$/);
if (m && m[1]) {
filesSet.add(m[1]);
}
}
}
}
return cmd;
}
function processNonExecTool(
toolName: string,
argsJson: unknown,
filesSet: Set<string>,
): string {
let summary = toolName;
if (argsJson && typeof argsJson === "object") {
// Extract a few common argument keys to make the summary more useful
// without being overly verbose.
const interestingKeys = ["path", "file", "filepath", "filename", "pattern"];
for (const key of interestingKeys) {
const val = (argsJson as Record<string, unknown>)[key];
if (typeof val === "string") {
summary += ` ${val}`;
if (val.includes("/")) {
filesSet.add(val);
}
break;
}
}
}
return summary;
}