Files
llmx/codex-cli/src/components/history-overlay.tsx
Ilan Bigio 59a180ddec Initial commit
Signed-off-by: Ilan Bigio <ilan@openai.com>
2025-04-16 12:56:08 -04:00

238 lines
7.0 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(() => buildLists(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 buildLists(items: Array<ResponseItem>): {
commands: Array<string>;
files: Array<string>;
} {
const commands: Array<string> = [];
const filesSet = new Set<string>();
for (const item of items) {
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.
const truncated =
fullPrompt.length > 120 ? `${fullPrompt.slice(0, 117)}` : fullPrompt;
commands.push(`> ${truncated}`);
}
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(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]);
}
}
}
}
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) {
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;
}
}
}
commands.push(summary);
}
}
return { commands, files: Array.from(filesSet) };
}