681
codex-cli/src/components/singlepass-cli-app.tsx
Normal file
681
codex-cli/src/components/singlepass-cli-app.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import type { AppConfig } from "../utils/config";
|
||||
import type { FileOperation } from "../utils/singlepass/file_ops";
|
||||
|
||||
import Spinner from "./vendor/ink-spinner"; // Third‑party / vendor components
|
||||
import TextInput from "./vendor/ink-text-input";
|
||||
import { OPENAI_TIMEOUT_MS, OPENAI_BASE_URL } from "../utils/config";
|
||||
import {
|
||||
generateDiffSummary,
|
||||
generateEditSummary,
|
||||
} from "../utils/singlepass/code_diff";
|
||||
import { renderTaskContext } from "../utils/singlepass/context";
|
||||
import {
|
||||
getFileContents,
|
||||
loadIgnorePatterns,
|
||||
makeAsciiDirectoryStructure,
|
||||
} from "../utils/singlepass/context_files";
|
||||
import { EditedFilesSchema } from "../utils/singlepass/file_ops";
|
||||
import * as fsSync from "fs";
|
||||
import * as fsPromises from "fs/promises";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import OpenAI from "openai";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import path from "path";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
/** Maximum number of characters allowed in the context passed to the model. */
|
||||
const MAX_CONTEXT_CHARACTER_LIMIT = 2_000_000;
|
||||
|
||||
// --- prompt history support (same as for rest of CLI) ---
|
||||
const PROMPT_HISTORY_KEY = "__codex_singlepass_prompt_history";
|
||||
function loadPromptHistory(): Array<string> {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const raw = localStorage.getItem(PROMPT_HISTORY_KEY);
|
||||
if (raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// fallback to process.env-based temp storage if localStorage isn't available
|
||||
try {
|
||||
if (process && process.env && process.env["HOME"]) {
|
||||
const p = path.join(
|
||||
process.env["HOME"],
|
||||
".codex_singlepass_history.json",
|
||||
);
|
||||
if (fsSync.existsSync(p)) {
|
||||
return JSON.parse(fsSync.readFileSync(p, "utf8"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function savePromptHistory(history: Array<string>) {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(PROMPT_HISTORY_KEY, JSON.stringify(history));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// fallback to process.env-based temp storage if localStorage isn't available
|
||||
try {
|
||||
if (process && process.env && process.env["HOME"]) {
|
||||
const p = path.join(
|
||||
process.env["HOME"],
|
||||
".codex_singlepass_history.json",
|
||||
);
|
||||
fsSync.writeFileSync(p, JSON.stringify(history), "utf8");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small animated spinner shown while the request to OpenAI is in‑flight.
|
||||
*/
|
||||
function WorkingSpinner({ text = "Working" }: { text?: string }) {
|
||||
const [dots, setDots] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDots((d) => (d.length < 3 ? d + "." : ""));
|
||||
}, 400);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{text}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryInfo({
|
||||
rootPath,
|
||||
files,
|
||||
contextLimit,
|
||||
showStruct = false,
|
||||
}: {
|
||||
rootPath: string;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
contextLimit: number;
|
||||
showStruct?: boolean;
|
||||
}) {
|
||||
const asciiStruct = React.useMemo(
|
||||
() =>
|
||||
showStruct
|
||||
? makeAsciiDirectoryStructure(
|
||||
rootPath,
|
||||
files.map((fc) => fc.path),
|
||||
)
|
||||
: null,
|
||||
[showStruct, rootPath, files],
|
||||
);
|
||||
const totalChars = files.reduce((acc, fc) => acc + fc.content.length, 0);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
width={80}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text> <Text bold>Directory:</Text>{" "}
|
||||
{rootPath}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>{" "}
|
||||
<Text bold>Paths in context:</Text> {rootPath} ({files.length} files)
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text> <Text bold>Context size:</Text>{" "}
|
||||
{totalChars} / {contextLimit} ( ~
|
||||
{((totalChars / contextLimit) * 100).toFixed(2)}% )
|
||||
</Text>
|
||||
{showStruct ? (
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>
|
||||
<Text bold>Context structure:</Text>
|
||||
<Text>{asciiStruct}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Text color="magentaBright">↳</Text>{" "}
|
||||
<Text bold>Context structure:</Text>{" "}
|
||||
<Text dimColor>
|
||||
Hidden. Type <Text color="cyan">/context</Text> to show it.
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{totalChars > contextLimit ? (
|
||||
<Text color="red">
|
||||
Files exceed context limit. See breakdown below.
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryAndDiffs({
|
||||
summary,
|
||||
diffs,
|
||||
}: {
|
||||
summary: string;
|
||||
diffs: string;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="yellow" bold>
|
||||
Summary:
|
||||
</Text>
|
||||
<Text>{summary}</Text>
|
||||
<Text color="cyan" bold>
|
||||
Proposed Diffs:
|
||||
</Text>
|
||||
<Text>{diffs}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Input prompts */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function InputPrompt({
|
||||
message,
|
||||
onSubmit,
|
||||
onCtrlC,
|
||||
}: {
|
||||
message: string;
|
||||
onSubmit: (val: string) => void;
|
||||
onCtrlC?: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history] = useState(() => loadPromptHistory());
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
const [, setShowDirInfo] = useState(false);
|
||||
|
||||
useInput((input, key) => {
|
||||
if ((key.ctrl && (input === "c" || input === "C")) || input === "\u0003") {
|
||||
// Ctrl+C pressed – treat as interrupt
|
||||
if (onCtrlC) {
|
||||
onCtrlC();
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (value.trim() !== "") {
|
||||
// Save to history (front of list)
|
||||
const updated =
|
||||
history[history.length - 1] === value ? history : [...history, value];
|
||||
savePromptHistory(updated.slice(-50));
|
||||
}
|
||||
onSubmit(value.trim());
|
||||
} else if (key.upArrow) {
|
||||
if (history.length > 0) {
|
||||
if (historyIndex == null) {
|
||||
setDraftInput(value);
|
||||
}
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(history[newIndex] ?? "");
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
if (historyIndex == null) {
|
||||
return;
|
||||
}
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setValue(draftInput);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setValue(history[newIndex] ?? "");
|
||||
}
|
||||
} else if (input === "/context" || input === ":context") {
|
||||
setShowDirInfo(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text>{message}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder="Type here…"
|
||||
showCursor
|
||||
focus
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationPrompt({
|
||||
message,
|
||||
onResult,
|
||||
}: {
|
||||
message: string;
|
||||
onResult: (accept: boolean) => void;
|
||||
}) {
|
||||
useInput((input, key) => {
|
||||
if (key.return || input.toLowerCase() === "y") {
|
||||
onResult(true);
|
||||
} else if (input.toLowerCase() === "n" || key.escape) {
|
||||
onResult(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text>{message} [y/N] </Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuePrompt({ onResult }: { onResult: (cont: boolean) => void }) {
|
||||
useInput((input, key) => {
|
||||
if (input.toLowerCase() === "y" || key.return) {
|
||||
onResult(true);
|
||||
} else if (input.toLowerCase() === "n" || key.escape) {
|
||||
onResult(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text>Do you want to apply another edit? [y/N] </Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface SinglePassAppProps {
|
||||
originalPrompt?: string;
|
||||
config: AppConfig;
|
||||
rootPath: string;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function SinglePassApp({
|
||||
originalPrompt,
|
||||
config,
|
||||
rootPath,
|
||||
onExit,
|
||||
}: SinglePassAppProps): JSX.Element {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState<
|
||||
| "init"
|
||||
| "prompt"
|
||||
| "thinking"
|
||||
| "confirm"
|
||||
| "skipped"
|
||||
| "applied"
|
||||
| "noops"
|
||||
| "error"
|
||||
| "interrupted"
|
||||
>("init");
|
||||
|
||||
// we don't need to read the current prompt / spinner state outside of
|
||||
// updating functions, so we intentionally ignore the first tuple element.
|
||||
const [, setPrompt] = useState(originalPrompt ?? "");
|
||||
const [files, setFiles] = useState<Array<{ path: string; content: string }>>(
|
||||
[],
|
||||
);
|
||||
const [diffInfo, setDiffInfo] = useState<{
|
||||
summary: string;
|
||||
diffs: string;
|
||||
ops: Array<FileOperation>;
|
||||
}>({ summary: "", diffs: "", ops: [] });
|
||||
const [, setShowSpinner] = useState(false);
|
||||
const [applyOps, setApplyOps] = useState<Array<FileOperation>>([]);
|
||||
const [quietExit, setQuietExit] = useState(false);
|
||||
const [showDirInfo, setShowDirInfo] = useState(false);
|
||||
const contextLimit = MAX_CONTEXT_CHARACTER_LIMIT;
|
||||
const inputPromptValueRef = useRef<string>("");
|
||||
|
||||
/* ---------------------------- Load file context --------------------------- */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const ignorePats = loadIgnorePatterns();
|
||||
const fileContents = await getFileContents(rootPath, ignorePats);
|
||||
setFiles(fileContents);
|
||||
})();
|
||||
}, [rootPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length) {
|
||||
setState("prompt");
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
/* -------------------------------- Helpers -------------------------------- */
|
||||
|
||||
async function runSinglePassTask(userPrompt: string) {
|
||||
setPrompt(userPrompt);
|
||||
setShowSpinner(true);
|
||||
setState("thinking");
|
||||
|
||||
try {
|
||||
const taskContextStr = renderTaskContext({
|
||||
prompt: userPrompt,
|
||||
input_paths: [rootPath],
|
||||
input_paths_structure: "(omitted for brevity in single pass mode)",
|
||||
files,
|
||||
});
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.apiKey ?? "",
|
||||
baseURL: OPENAI_BASE_URL || undefined,
|
||||
timeout: OPENAI_TIMEOUT_MS,
|
||||
});
|
||||
const chatResp = await openai.beta.chat.completions.parse({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: taskContextStr,
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(EditedFilesSchema, "schema"),
|
||||
});
|
||||
|
||||
const edited = chatResp.choices[0]?.message?.parsed ?? null;
|
||||
|
||||
setShowSpinner(false);
|
||||
|
||||
if (!edited || !Array.isArray(edited.ops)) {
|
||||
setState("noops");
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMap: Record<string, string> = {};
|
||||
for (const fc of files) {
|
||||
originalMap[fc.path] = fc.content;
|
||||
}
|
||||
|
||||
const [combinedDiffs, opsToApply] = generateDiffSummary(
|
||||
edited,
|
||||
originalMap,
|
||||
);
|
||||
|
||||
if (!opsToApply.length) {
|
||||
setState("noops");
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = generateEditSummary(opsToApply, originalMap);
|
||||
setDiffInfo({ summary, diffs: combinedDiffs, ops: opsToApply });
|
||||
setApplyOps(opsToApply);
|
||||
setState("confirm");
|
||||
} catch (err) {
|
||||
setShowSpinner(false);
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFileOps(ops: Array<FileOperation>) {
|
||||
for (const op of ops) {
|
||||
if (op.delete) {
|
||||
try {
|
||||
await fsPromises.unlink(op.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (op.move_to) {
|
||||
const newContent = op.updated_full_content || "";
|
||||
try {
|
||||
await fsPromises.mkdir(path.dirname(op.move_to), { recursive: true });
|
||||
await fsPromises.writeFile(op.move_to, newContent, "utf-8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(op.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
const newContent = op.updated_full_content || "";
|
||||
try {
|
||||
await fsPromises.mkdir(path.dirname(op.path), { recursive: true });
|
||||
await fsPromises.writeFile(op.path, newContent, "utf-8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
setState("applied");
|
||||
}
|
||||
|
||||
/* --------------------------------- Render -------------------------------- */
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (state === "applied") {
|
||||
setState("prompt");
|
||||
} else if (
|
||||
(key.ctrl && (_input === "c" || _input === "C")) ||
|
||||
_input === "\u0003"
|
||||
) {
|
||||
// If in thinking mode, treat this as an interrupt and reset to prompt
|
||||
if (state === "thinking") {
|
||||
setState("interrupted");
|
||||
// If you want to exit the process altogether instead:
|
||||
// app.exit();
|
||||
// if (onExit) onExit();
|
||||
} else if (state === "prompt") {
|
||||
// Ctrl+C in prompt mode quits
|
||||
app.exit();
|
||||
if (onExit) {
|
||||
onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (quietExit) {
|
||||
setTimeout(() => {
|
||||
onExit && onExit();
|
||||
app.exit();
|
||||
}, 100);
|
||||
return <Text>Session complete.</Text>;
|
||||
}
|
||||
|
||||
if (state === "init") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>Directory: {rootPath}</Text>
|
||||
<Text color="gray">Loading file context…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Error calling OpenAI API.</Text>
|
||||
<ContinuePrompt
|
||||
onResult={(cont) => {
|
||||
if (!cont) {
|
||||
setQuietExit(true);
|
||||
} else {
|
||||
setState("prompt");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "noops") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">No valid operations returned.</Text>
|
||||
<ContinuePrompt
|
||||
onResult={(cont) => {
|
||||
if (!cont) {
|
||||
setQuietExit(true);
|
||||
} else {
|
||||
setState("prompt");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "applied") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="green">Changes have been applied.</Text>
|
||||
<Text color="gray">Press any key to continue…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "thinking") {
|
||||
return <WorkingSpinner />;
|
||||
}
|
||||
|
||||
if (state === "interrupted") {
|
||||
// Reset prompt input value (clears what was typed before interruption)
|
||||
inputPromptValueRef.current = "";
|
||||
setTimeout(() => setState("prompt"), 250);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">
|
||||
Interrupted. Press Enter to return to prompt mode.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "prompt") {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* Info Box */}
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1} width={80}>
|
||||
<Text>
|
||||
<Text bold color="magenta">
|
||||
OpenAI <Text bold>Codex</Text>
|
||||
</Text>{" "}
|
||||
<Text dimColor>(full context mode)</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color="greenBright">
|
||||
→
|
||||
</Text>{" "}
|
||||
<Text bold>Model:</Text> {config.model}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Directory info */}
|
||||
<DirectoryInfo
|
||||
rootPath={rootPath}
|
||||
files={files}
|
||||
contextLimit={contextLimit}
|
||||
showStruct={showDirInfo}
|
||||
/>
|
||||
|
||||
{/* Prompt Input Box */}
|
||||
<Box borderStyle="round" paddingX={1}>
|
||||
<InputPrompt
|
||||
message=">>> "
|
||||
onSubmit={(val) => {
|
||||
// Support /context as a command to show the directory structure.
|
||||
if (val === "/context" || val === ":context") {
|
||||
setShowDirInfo(true);
|
||||
setPrompt("");
|
||||
return;
|
||||
} else {
|
||||
setShowDirInfo(false);
|
||||
}
|
||||
|
||||
// Continue if prompt is empty.
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
runSinglePassTask(val);
|
||||
}}
|
||||
onCtrlC={() => {
|
||||
setState("interrupted");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{"Type /context to display the directory structure."}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" Press Ctrl+C at any time to interrupt / exit."}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "confirm") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SummaryAndDiffs summary={diffInfo.summary} diffs={diffInfo.diffs} />
|
||||
<ConfirmationPrompt
|
||||
message="Apply these changes?"
|
||||
onResult={(accept) => {
|
||||
if (accept) {
|
||||
applyFileOps(applyOps);
|
||||
} else {
|
||||
setState("skipped");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "skipped") {
|
||||
setTimeout(() => {
|
||||
setState("prompt");
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Skipped proposed changes.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color="gray">…</Text>;
|
||||
}
|
||||
|
||||
export default {};
|
||||
Reference in New Issue
Block a user