80
codex-cli/src/components/chat/message-history.tsx
Normal file
80
codex-cli/src/components/chat/message-history.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { TerminalHeaderProps } from "./terminal-header.js";
|
||||
import type { GroupedResponseItem } from "./use-message-grouping.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
import { Box, Static } from "ink";
|
||||
import React from "react";
|
||||
|
||||
// A batch entry can either be a standalone response item or a grouped set of
|
||||
// items (e.g. auto‑approved tool‑call batches) that should be rendered
|
||||
// together.
|
||||
type BatchEntry = { item?: ResponseItem; group?: GroupedResponseItem };
|
||||
type MessageHistoryProps = {
|
||||
batch: Array<BatchEntry>;
|
||||
groupCounts: Record<string, number>;
|
||||
items: Array<ResponseItem>;
|
||||
userMsgCount: number;
|
||||
confirmationPrompt: React.ReactNode;
|
||||
loading: boolean;
|
||||
headerProps: TerminalHeaderProps;
|
||||
};
|
||||
|
||||
const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
batch,
|
||||
headerProps,
|
||||
}) => {
|
||||
const messages = batch.map(({ item }) => item!);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/*
|
||||
* The Static component receives a mixed array of the literal string
|
||||
* "header" plus the streamed ResponseItem objects. After filtering out
|
||||
* the header entry we can safely treat the remaining values as
|
||||
* ResponseItem, however TypeScript cannot infer the refined type from
|
||||
* the runtime check and therefore reports property‑access errors.
|
||||
*
|
||||
* A short cast after the refinement keeps the implementation tidy while
|
||||
* preserving type‑safety.
|
||||
*/}
|
||||
<Static items={["header", ...messages]}>
|
||||
{(item, index) => {
|
||||
if (item === "header") {
|
||||
return <TerminalHeader key="header" {...headerProps} />;
|
||||
}
|
||||
|
||||
// After the guard above `item` can only be a ResponseItem.
|
||||
const message = item as ResponseItem;
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
borderStyle={
|
||||
message.type === "message" && message.role === "user"
|
||||
? "round"
|
||||
: undefined
|
||||
}
|
||||
borderColor={
|
||||
message.type === "message" && message.role === "user"
|
||||
? "gray"
|
||||
: undefined
|
||||
}
|
||||
marginLeft={
|
||||
message.type === "message" && message.role === "user" ? 0 : 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem item={message} />
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Static>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHistory);
|
||||
409
codex-cli/src/components/chat/multiline-editor.tsx
Normal file
409
codex-cli/src/components/chat/multiline-editor.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size";
|
||||
import TextBuffer from "../../lib/text-buffer.js";
|
||||
import chalk from "chalk";
|
||||
import { Box, Text, useInput, useStdin } from "ink";
|
||||
import { EventEmitter } from "node:events";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Polyfill missing `ref()` / `unref()` methods on the mock `Stdin` stream
|
||||
* provided by `ink-testing-library`.
|
||||
*
|
||||
* The real `process.stdin` object exposed by Node.js inherits these methods
|
||||
* from `Socket`, but the lightweight stub used in tests only extends
|
||||
* `EventEmitter`. Ink calls the two methods when enabling/disabling raw
|
||||
* mode, so make them harmless no‑ops when they're absent to avoid runtime
|
||||
* failures during unit tests.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
// Cast through `unknown` ➜ `any` to avoid the `TS2352`/`TS4111` complaints
|
||||
// when augmenting the prototype with the stubbed `ref`/`unref` methods in the
|
||||
// test environment. Using `any` here is acceptable because we purposefully
|
||||
// monkey‑patch internals of Node's `EventEmitter` solely for the benefit of
|
||||
// Ink's stdin stub – type‑safety is not a primary concern at this boundary.
|
||||
//
|
||||
const proto: any = EventEmitter.prototype;
|
||||
|
||||
if (typeof proto["ref"] !== "function") {
|
||||
proto["ref"] = function ref() {};
|
||||
}
|
||||
if (typeof proto["unref"] !== "function") {
|
||||
proto["unref"] = function unref() {};
|
||||
}
|
||||
|
||||
/*
|
||||
* The `ink-testing-library` stub emits only a `data` event when its `stdin`
|
||||
* mock receives `write()` calls. Ink, however, listens for `readable` and
|
||||
* uses the `read()` method to fetch the buffered chunk. Bridge the gap by
|
||||
* hooking into `EventEmitter.emit` so that every `data` emission also:
|
||||
* 1. Buffers the chunk for a subsequent `read()` call, and
|
||||
* 2. Triggers a `readable` event, matching the contract expected by Ink.
|
||||
*/
|
||||
|
||||
// Preserve original emit to avoid infinite recursion.
|
||||
// eslint‑disable‑next‑line @typescript-eslint/no‑unsafe‑assignment
|
||||
const originalEmit = proto["emit"] as (...args: Array<any>) => boolean;
|
||||
|
||||
proto["emit"] = function patchedEmit(
|
||||
this: any,
|
||||
event: string,
|
||||
...args: Array<any>
|
||||
): boolean {
|
||||
if (event === "data") {
|
||||
const chunk = args[0] as string;
|
||||
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor:stdin] data", JSON.stringify(chunk));
|
||||
}
|
||||
// Store carriage returns as‑is so that Ink can distinguish between plain
|
||||
// <Enter> ("\r") and a bare line‑feed ("\n"). This matters because Ink's
|
||||
// `parseKeypress` treats "\r" as key.name === "return", whereas "\n" maps
|
||||
// to "enter" – allowing us to differentiate between plain Enter (submit)
|
||||
// and Shift+Enter (insert newline) inside `useInput`.
|
||||
|
||||
// Identify the lightweight testing stub: lacks `.read()` but exposes
|
||||
// `.setRawMode()` and `isTTY` similar to the real TTY stream.
|
||||
if (
|
||||
!(this as any)._inkIsStub &&
|
||||
typeof (this as any).setRawMode === "function" &&
|
||||
typeof (this as any).isTTY === "boolean" &&
|
||||
typeof (this as any).read !== "function"
|
||||
) {
|
||||
(this as any)._inkIsStub = true;
|
||||
|
||||
// Provide a minimal `read()` shim so Ink can pull queued chunks.
|
||||
(this as any).read = function read() {
|
||||
const ret = (this as any)._inkBuffered ?? null;
|
||||
(this as any)._inkBuffered = null;
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor:stdin.read]", JSON.stringify(ret));
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
|
||||
if ((this as any)._inkIsStub) {
|
||||
// Buffer the payload so that `read()` can synchronously retrieve it.
|
||||
if (typeof (this as any)._inkBuffered === "string") {
|
||||
(this as any)._inkBuffered += chunk;
|
||||
} else {
|
||||
(this as any)._inkBuffered = chunk;
|
||||
}
|
||||
|
||||
// Notify listeners that data is ready in a way Ink understands.
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"[MultilineTextEditor:stdin] -> readable",
|
||||
JSON.stringify(chunk),
|
||||
);
|
||||
}
|
||||
originalEmit.call(this, "readable");
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the original event.
|
||||
return originalEmit.call(this, event, ...args);
|
||||
};
|
||||
|
||||
export interface MultilineTextEditorProps {
|
||||
// Initial contents.
|
||||
readonly initialText?: string;
|
||||
|
||||
// Visible width.
|
||||
readonly width?: number;
|
||||
|
||||
// Visible height.
|
||||
readonly height?: number;
|
||||
|
||||
// Called when the user submits (plain <Enter> key).
|
||||
readonly onSubmit?: (text: string) => void;
|
||||
|
||||
// Capture keyboard input.
|
||||
readonly focus?: boolean;
|
||||
|
||||
// Called when the internal text buffer updates.
|
||||
readonly onChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
// Expose a minimal imperative API so parent components (e.g. TerminalChatInput)
|
||||
// can query the caret position to implement behaviours like history
|
||||
// navigation that depend on whether the cursor sits on the first/last line.
|
||||
export interface MultilineTextEditorHandle {
|
||||
/** Current caret row */
|
||||
getRow(): number;
|
||||
/** Current caret column */
|
||||
getCol(): number;
|
||||
/** Total number of lines in the buffer */
|
||||
getLineCount(): number;
|
||||
/** Helper: caret is on the very first row */
|
||||
isCursorAtFirstRow(): boolean;
|
||||
/** Helper: caret is on the very last row */
|
||||
isCursorAtLastRow(): boolean;
|
||||
/** Full text contents */
|
||||
getText(): string;
|
||||
}
|
||||
|
||||
const MultilineTextEditorInner = (
|
||||
{
|
||||
initialText = "",
|
||||
// Width can be provided by the caller. When omitted we fall back to the
|
||||
// current terminal size (minus some padding handled by `useTerminalSize`).
|
||||
width,
|
||||
height = 10,
|
||||
onSubmit,
|
||||
focus = true,
|
||||
onChange,
|
||||
}: MultilineTextEditorProps,
|
||||
ref: React.Ref<MultilineTextEditorHandle | null>,
|
||||
): React.ReactElement => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buffer = useRef(new TextBuffer(initialText));
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Keep track of the current terminal size so that the editor grows/shrinks
|
||||
// with the window. `useTerminalSize` already subtracts a small horizontal
|
||||
// padding so that we don't butt up right against the edge.
|
||||
const terminalSize = useTerminalSize();
|
||||
|
||||
// If the caller didn't specify a width we dynamically choose one based on
|
||||
// the terminal's current column count. We still enforce a reasonable
|
||||
// minimum so that the UI never becomes unusably small.
|
||||
const effectiveWidth = Math.max(20, width ?? terminalSize.columns);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External editor integration helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Access to stdin so we can toggle raw‑mode while the external editor is
|
||||
// in control of the terminal.
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
/**
|
||||
* Launch the user's preferred $EDITOR, blocking until they close it, then
|
||||
* reload the edited file back into the in‑memory TextBuffer. The heavy
|
||||
* work is delegated to `TextBuffer.openInExternalEditor`, but we are
|
||||
* responsible for temporarily *disabling* raw mode so the child process can
|
||||
* interact with the TTY normally.
|
||||
*/
|
||||
const openExternalEditor = React.useCallback(async () => {
|
||||
// Preserve the current raw‑mode setting so we can restore it afterwards.
|
||||
const wasRaw = stdin?.isRaw ?? false;
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
await buffer.current.openInExternalEditor();
|
||||
} catch (err) {
|
||||
// Surface the error so it doesn't fail silently – for now we log to
|
||||
// stderr. In the future this could surface a toast / overlay.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[MultilineTextEditor] external editor error", err);
|
||||
} finally {
|
||||
if (wasRaw) {
|
||||
setRawMode?.(true);
|
||||
}
|
||||
// Force a re‑render so the component reflects the mutated buffer.
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
}, [buffer, stdin, setRawMode]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard handling.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single‑step editor shortcut: Ctrl+X or Ctrl+E
|
||||
// Treat both true Ctrl+Key combinations *and* raw control codes so that
|
||||
// the shortcut works consistently in real terminals (raw‑mode) and the
|
||||
// ink‑testing‑library stub which delivers only the raw byte (e.g. 0x05
|
||||
// for Ctrl‑E) without setting `key.ctrl`.
|
||||
const isCtrlX =
|
||||
(key.ctrl && (input === "x" || input === "\x18")) || input === "\x18";
|
||||
const isCtrlE =
|
||||
(key.ctrl && (input === "e" || input === "\x05")) ||
|
||||
input === "\x05" ||
|
||||
(!key.ctrl &&
|
||||
input === "e" &&
|
||||
input.length === 1 &&
|
||||
input.charCodeAt(0) === 5);
|
||||
if (isCtrlX || isCtrlE) {
|
||||
openExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor] event", { input, key });
|
||||
}
|
||||
|
||||
// 1) CSI‑u / modifyOtherKeys (Ink strips initial ESC, so we start with '[')
|
||||
if (input.startsWith("[") && input.endsWith("u")) {
|
||||
const m = input.match(/^\[([0-9]+);([0-9]+)u$/);
|
||||
if (m && m[1] === "13") {
|
||||
const mod = Number(m[2]);
|
||||
// In xterm's encoding: bit‑1 (value 2) is Shift. Everything >1 that
|
||||
// isn't exactly 1 means some modifier was held. We treat *shift
|
||||
// present* (2,4,6,8) as newline; plain (1) as submit.
|
||||
|
||||
// Xterm encodes modifier keys in `mod` – bit‑2 (value 4) indicates
|
||||
// that Ctrl was held. We avoid the `&` bitwise operator (disallowed
|
||||
// by our ESLint config) by using arithmetic instead.
|
||||
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
|
||||
if (hasCtrl) {
|
||||
if (onSubmit) {
|
||||
onSubmit(buffer.current.getText());
|
||||
}
|
||||
} else {
|
||||
// Any variant without Ctrl just inserts newline (Shift, Alt, none)
|
||||
buffer.current.newline();
|
||||
}
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Single‑byte control chars ------------------------------------------------
|
||||
if (input === "\n") {
|
||||
// Ctrl+J or pasted newline → insert newline.
|
||||
buffer.current.newline();
|
||||
setVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === "\r") {
|
||||
// Plain Enter – submit (works on all basic terminals).
|
||||
if (onSubmit) {
|
||||
onSubmit(buffer.current.getText());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Let <Esc> fall through so the parent handler (if any) can act on it.
|
||||
|
||||
// Delegate remaining keys to our pure TextBuffer
|
||||
if (
|
||||
process.env["TEXTBUFFER_DEBUG"] === "1" ||
|
||||
process.env["TEXTBUFFER_DEBUG"] === "true"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[MultilineTextEditor] key event", { input, key });
|
||||
}
|
||||
|
||||
const modified = buffer.current.handleInput(
|
||||
input,
|
||||
key as Record<string, boolean>,
|
||||
{ height, width: effectiveWidth },
|
||||
);
|
||||
if (modified) {
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
const newText = buffer.current.getText();
|
||||
if (onChange) {
|
||||
onChange(newText);
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Imperative handle – expose a read‑only view of caret & buffer geometry */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getRow: () => buffer.current.getCursor()[0],
|
||||
getCol: () => buffer.current.getCursor()[1],
|
||||
getLineCount: () => buffer.current.getText().split("\n").length,
|
||||
isCursorAtFirstRow: () => buffer.current.getCursor()[0] === 0,
|
||||
isCursorAtLastRow: () => {
|
||||
const [row] = buffer.current.getCursor();
|
||||
const lineCount = buffer.current.getText().split("\n").length;
|
||||
return row === lineCount - 1;
|
||||
},
|
||||
getText: () => buffer.current.getText(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Read everything from the buffer
|
||||
const visibleLines = buffer.current.getVisibleLines({
|
||||
height,
|
||||
width: effectiveWidth,
|
||||
});
|
||||
const [cursorRow, cursorCol] = buffer.current.getCursor();
|
||||
const scrollRow = (buffer.current as any).scrollRow as number;
|
||||
const scrollCol = (buffer.current as any).scrollCol as number;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={version}>
|
||||
{visibleLines.map((lineText, idx) => {
|
||||
const absoluteRow = scrollRow + idx;
|
||||
|
||||
// apply horizontal slice
|
||||
let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
|
||||
if (display.length < effectiveWidth) {
|
||||
display = display.padEnd(effectiveWidth, " ");
|
||||
}
|
||||
|
||||
// Highlight the *character under the caret* (i.e. the one immediately
|
||||
// to the right of the insertion position) so that the block cursor
|
||||
// visually matches the logical caret location. This makes the
|
||||
// highlighted glyph the one that would be replaced by `insert()` and
|
||||
// *not* the one that would be removed by `backspace()`.
|
||||
|
||||
if (absoluteRow === cursorRow) {
|
||||
const relativeCol = cursorCol - scrollCol;
|
||||
const highlightCol = relativeCol;
|
||||
|
||||
if (highlightCol >= 0 && highlightCol < effectiveWidth) {
|
||||
const charToHighlight = display[highlightCol] || " ";
|
||||
const highlighted = chalk.inverse(charToHighlight);
|
||||
display =
|
||||
display.slice(0, highlightCol) +
|
||||
highlighted +
|
||||
display.slice(highlightCol + 1);
|
||||
} else if (relativeCol === effectiveWidth) {
|
||||
// Caret sits just past the right edge; show a block cursor in the
|
||||
// gutter so the user still sees it.
|
||||
display = display.slice(0, effectiveWidth - 1) + chalk.inverse(" ");
|
||||
}
|
||||
}
|
||||
|
||||
return <Text key={idx}>{display}</Text>;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MultilineTextEditor = React.forwardRef(MultilineTextEditorInner);
|
||||
|
||||
export default MultilineTextEditor;
|
||||
172
codex-cli/src/components/chat/terminal-chat-command-review.tsx
Normal file
172
codex-cli/src/components/chat/terminal-chat-command-review.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ReviewDecision } from "../../utils/agent/review";
|
||||
// TODO: figure out why `cli-spinners` fails on Node v20.9.0
|
||||
// which is why we have to do this in the first place
|
||||
//
|
||||
// @ts-expect-error select.js is JavaScript and has no types
|
||||
import { Select } from "../vendor/ink-select/select";
|
||||
import TextInput from "../vendor/ink-text-input";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React from "react";
|
||||
|
||||
// default deny‑reason:
|
||||
const DEFAULT_DENY_MESSAGE =
|
||||
"Don't do that, but keep trying to fix the problem";
|
||||
|
||||
export function TerminalChatCommandReview({
|
||||
confirmationPrompt,
|
||||
onReviewCommand,
|
||||
}: {
|
||||
confirmationPrompt: React.ReactNode;
|
||||
onReviewCommand: (decision: ReviewDecision, customMessage?: string) => void;
|
||||
}): React.ReactElement {
|
||||
const [mode, setMode] = React.useState<"select" | "input">("select");
|
||||
const [msg, setMsg] = React.useState<string>("");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Determine whether the "always approve" option should be displayed. We
|
||||
// only hide it for the special `apply_patch` command since approving those
|
||||
// permanently would bypass the user's review of future file modifications.
|
||||
// The information is embedded in the `confirmationPrompt` React element –
|
||||
// we inspect the `commandForDisplay` prop exposed by
|
||||
// <TerminalChatToolCallCommand/> to extract the base command.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const showAlwaysApprove = React.useMemo(() => {
|
||||
if (
|
||||
React.isValidElement(confirmationPrompt) &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (confirmationPrompt as any).props?.commandForDisplay === "string"
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const command: string = (confirmationPrompt as any).props
|
||||
.commandForDisplay;
|
||||
// Grab the first token of the first line – that corresponds to the base
|
||||
// command even when the string contains embedded newlines (e.g. diffs).
|
||||
const baseCmd = command.split("\n")[0]?.trim().split(/\s+/)[0] ?? "";
|
||||
return baseCmd !== "apply_patch";
|
||||
}
|
||||
// Default to showing the option when we cannot reliably detect the base
|
||||
// command.
|
||||
return true;
|
||||
}, [confirmationPrompt]);
|
||||
|
||||
// Memoize the list of selectable options to avoid recreating the array on
|
||||
// every render. This keeps <Select/> stable and prevents unnecessary work
|
||||
// inside Ink.
|
||||
const approvalOptions = React.useMemo(() => {
|
||||
const opts: Array<
|
||||
| { label: string; value: ReviewDecision }
|
||||
| { label: string; value: "edit" }
|
||||
> = [
|
||||
{
|
||||
label: "Yes (y)",
|
||||
value: ReviewDecision.YES,
|
||||
},
|
||||
];
|
||||
|
||||
if (showAlwaysApprove) {
|
||||
opts.push({
|
||||
label: "Yes, always approve this exact command for this session (a)",
|
||||
value: ReviewDecision.ALWAYS,
|
||||
});
|
||||
}
|
||||
|
||||
opts.push(
|
||||
{
|
||||
label: "Edit or give feedback (e)",
|
||||
value: "edit",
|
||||
},
|
||||
{
|
||||
label: "No, and keep going (n)",
|
||||
value: ReviewDecision.NO_CONTINUE,
|
||||
},
|
||||
{
|
||||
label: "No, and stop for now (esc)",
|
||||
value: ReviewDecision.NO_EXIT,
|
||||
},
|
||||
);
|
||||
|
||||
return opts;
|
||||
}, [showAlwaysApprove]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (mode === "select") {
|
||||
if (input === "y") {
|
||||
onReviewCommand(ReviewDecision.YES);
|
||||
} else if (input === "e") {
|
||||
setMode("input");
|
||||
} else if (input === "n") {
|
||||
onReviewCommand(
|
||||
ReviewDecision.NO_CONTINUE,
|
||||
"Don't do that, keep going though",
|
||||
);
|
||||
} else if (input === "a" && showAlwaysApprove) {
|
||||
onReviewCommand(ReviewDecision.ALWAYS);
|
||||
} else if (key.escape) {
|
||||
onReviewCommand(ReviewDecision.NO_EXIT);
|
||||
}
|
||||
} else {
|
||||
// text entry mode
|
||||
if (key.return) {
|
||||
// if user hit enter on empty msg, fall back to DEFAULT_DENY_MESSAGE
|
||||
const custom = msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg;
|
||||
onReviewCommand(ReviewDecision.NO_CONTINUE, custom);
|
||||
} else if (key.escape) {
|
||||
// treat escape as denial with default message as well
|
||||
onReviewCommand(
|
||||
ReviewDecision.NO_CONTINUE,
|
||||
msg.trim() === "" ? DEFAULT_DENY_MESSAGE : msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} borderStyle="round" marginTop={1}>
|
||||
{confirmationPrompt}
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{mode === "select" ? (
|
||||
<>
|
||||
<Text>Allow command?</Text>
|
||||
<Box paddingX={2} flexDirection="column" gap={1}>
|
||||
<Select
|
||||
onChange={(value: ReviewDecision | "edit") => {
|
||||
if (value === "edit") {
|
||||
setMode("input");
|
||||
} else {
|
||||
onReviewCommand(value);
|
||||
}
|
||||
}}
|
||||
options={approvalOptions}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>Give the model feedback (↵ to submit):</Text>
|
||||
<Box borderStyle="round">
|
||||
<Box paddingX={1}>
|
||||
<TextInput
|
||||
value={msg}
|
||||
onChange={setMsg}
|
||||
placeholder="type a reason"
|
||||
showCursor
|
||||
focus
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{msg.trim() === "" && (
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
default:
|
||||
<Text>{DEFAULT_DENY_MESSAGE}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
173
codex-cli/src/components/chat/terminal-chat-input-thinking.tsx
Normal file
173
codex-cli/src/components/chat/terminal-chat-input-thinking.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import { Box, Text, useInput, useStdin } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const thinkingTexts = ["Thinking"]; /* [
|
||||
"Consulting the rubber duck",
|
||||
"Maximizing paperclips",
|
||||
"Reticulating splines",
|
||||
"Immanentizing the Eschaton",
|
||||
"Thinking",
|
||||
"Thinking about thinking",
|
||||
"Spinning in circles",
|
||||
"Counting dust specks",
|
||||
"Updating priors",
|
||||
"Feeding the utility monster",
|
||||
"Taking off",
|
||||
"Wireheading",
|
||||
"Counting to infinity",
|
||||
"Staring into the Basilisk",
|
||||
"Negotiationing acausal trades",
|
||||
"Searching the library of babel",
|
||||
"Multiplying matrices",
|
||||
"Solving the halting problem",
|
||||
"Counting grains of sand",
|
||||
"Simulating a simulation",
|
||||
"Asking the oracle",
|
||||
"Detangling qubits",
|
||||
"Reading tea leaves",
|
||||
"Pondering universal love and transcendant joy",
|
||||
"Feeling the AGI",
|
||||
"Shaving the yak",
|
||||
"Escaping local minima",
|
||||
"Pruning the search tree",
|
||||
"Descending the gradient",
|
||||
"Bikeshedding",
|
||||
"Securing funding",
|
||||
"Rewriting in Rust",
|
||||
"Engaging infinite improbability drive",
|
||||
"Clapping with one hand",
|
||||
"Synthesizing",
|
||||
"Rebasing thesis onto antithesis",
|
||||
"Transcending the loop",
|
||||
"Frogeposting",
|
||||
"Summoning",
|
||||
"Peeking beyond the veil",
|
||||
"Seeking",
|
||||
"Entering deep thought",
|
||||
"Meditating",
|
||||
"Decomposing",
|
||||
"Creating",
|
||||
"Beseeching the machine spirit",
|
||||
"Calibrating moral compass",
|
||||
"Collapsing the wave function",
|
||||
"Doodling",
|
||||
"Translating whale song",
|
||||
"Whispering to silicon",
|
||||
"Looking for semicolons",
|
||||
"Asking ChatGPT",
|
||||
"Bargaining with entropy",
|
||||
"Channeling",
|
||||
"Cooking",
|
||||
"Parrotting stochastically",
|
||||
]; */
|
||||
|
||||
export default function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
const [thinkingText, setThinkingText] = useState(
|
||||
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
|
||||
);
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
setThinkingText((prev) => {
|
||||
let next = prev;
|
||||
if (thinkingTexts.length > 1) {
|
||||
while (next === prev) {
|
||||
next =
|
||||
thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
active ? 30000 : null,
|
||||
);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{thinkingText}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
409
codex-cli/src/components/chat/terminal-chat-input.tsx
Normal file
409
codex-cli/src/components/chat/terminal-chat-input.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
ResponseItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import TextInput from "../vendor/ink-text-input.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React, { useCallback, useState, Fragment } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const suggestions = [
|
||||
"explain this codebase to me",
|
||||
"fix any build errors",
|
||||
"are there any bugs in my code?",
|
||||
];
|
||||
|
||||
export default function TerminalChatInput({
|
||||
isNew,
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
contextLeftPercent,
|
||||
openOverlay,
|
||||
openModelOverlay,
|
||||
openApprovalOverlay,
|
||||
openHelpOverlay,
|
||||
interruptAgent,
|
||||
active,
|
||||
}: {
|
||||
isNew: boolean;
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) => void;
|
||||
setLastResponseId: (lastResponseId: string) => void;
|
||||
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
|
||||
contextLeftPercent: number;
|
||||
openOverlay: () => void;
|
||||
openModelOverlay: () => void;
|
||||
openApprovalOverlay: () => void;
|
||||
openHelpOverlay: () => void;
|
||||
interruptAgent: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const app = useApp();
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<Array<string>>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
|
||||
useInput(
|
||||
(_input, _key) => {
|
||||
if (!confirmationPrompt && !loading) {
|
||||
if (_key.upArrow) {
|
||||
if (history.length > 0) {
|
||||
if (historyIndex == null) {
|
||||
setDraftInput(input);
|
||||
}
|
||||
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_key.downArrow) {
|
||||
if (historyIndex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setInput(draftInput);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.trim() === "" && isNew) {
|
||||
if (_key.tab) {
|
||||
setSelectedSuggestion(
|
||||
(s) => (s + (_key.shift ? -1 : 1)) % (suggestions.length + 1),
|
||||
);
|
||||
} else if (selectedSuggestion && _key.return) {
|
||||
const suggestion = suggestions[selectedSuggestion - 1] || "";
|
||||
setInput("");
|
||||
setSelectedSuggestion(0);
|
||||
submitInput([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: suggestion }],
|
||||
type: "message",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (_input === "\u0003" || (_input === "c" && _key.ctrl)) {
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const inputValue = value.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/history") {
|
||||
setInput("");
|
||||
openOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/help") {
|
||||
setInput("");
|
||||
openHelpOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/model")) {
|
||||
setInput("");
|
||||
openModelOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/approval")) {
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") {
|
||||
setInput("");
|
||||
// wait one 60ms frame
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
return;
|
||||
} else if (inputValue === "/clear" || inputValue === "clear") {
|
||||
setInput("");
|
||||
setSessionId("");
|
||||
setLastResponseId("");
|
||||
clearTerminal();
|
||||
|
||||
// Emit a system message to confirm the clear action. We *append*
|
||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `clear-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: "Context cleared" }],
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const images: Array<string> = [];
|
||||
const text = inputValue
|
||||
.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
const inputItem = await createInputItem(text, images);
|
||||
submitInput([inputItem]);
|
||||
setHistory((prev) => {
|
||||
if (prev[prev.length - 1] === value) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, value];
|
||||
});
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
setSelectedSuggestion(0);
|
||||
setInput("");
|
||||
},
|
||||
[
|
||||
setInput,
|
||||
submitInput,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
app,
|
||||
setHistory,
|
||||
setHistoryIndex,
|
||||
openOverlay,
|
||||
openApprovalOverlay,
|
||||
openModelOverlay,
|
||||
openHelpOverlay,
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmationPrompt) {
|
||||
return (
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round">
|
||||
{loading ? (
|
||||
<TerminalChatInputThinking
|
||||
onInterrupt={interruptAgent}
|
||||
active={active}
|
||||
/>
|
||||
) : (
|
||||
<Box paddingX={1}>
|
||||
<TextInput
|
||||
focus={active}
|
||||
placeholder={
|
||||
selectedSuggestion
|
||||
? `"${suggestions[selectedSuggestion - 1]}"`
|
||||
: "send a message" +
|
||||
(isNew ? " or press tab to select a suggestion" : "")
|
||||
}
|
||||
showCursor
|
||||
value={input}
|
||||
onChange={(value) => {
|
||||
setDraftInput(value);
|
||||
if (historyIndex != null) {
|
||||
setHistoryIndex(null);
|
||||
}
|
||||
setInput(value);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
{isNew && !input ? (
|
||||
<>
|
||||
try:{" "}
|
||||
{suggestions.map((m, key) => (
|
||||
<Fragment key={key}>
|
||||
{key !== 0 ? " | " : ""}
|
||||
<Text
|
||||
backgroundColor={
|
||||
key + 1 === selectedSuggestion ? "blackBright" : ""
|
||||
}
|
||||
>
|
||||
{m}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
|
||||
for commands | press enter to send
|
||||
{contextLeftPercent < 25 && (
|
||||
<>
|
||||
{" — "}
|
||||
<Text color="red">
|
||||
{Math.round(contextLeftPercent)}% context left
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Raw stdin listener to catch the case where the terminal delivers two
|
||||
// consecutive ESC bytes ("\x1B\x1B") in a *single* chunk. Ink's `useInput`
|
||||
// collapses that sequence into one key event, so the regular two‑step
|
||||
// handler above never sees the second press. By inspecting the raw data
|
||||
// we can identify this special case and trigger the interrupt while still
|
||||
// requiring a double press for the normal single‑byte ESC events.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure raw mode – already enabled by Ink when the component has focus,
|
||||
// but called defensively in case that assumption ever changes.
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return; // already awaiting a second explicit press
|
||||
}
|
||||
|
||||
// Handle both Buffer and string forms.
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
// Treat as the first Escape press – prompt the user for confirmation.
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
// Cycle the "Thinking…" animation dots.
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
// Listen for the escape key to allow the user to interrupt the current
|
||||
// operation. We require two presses within a short window (1.5s) to avoid
|
||||
// accidental cancellations.
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>Thinking{dots}</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
506
codex-cli/src/components/chat/terminal-chat-new-input.tsx
Normal file
506
codex-cli/src/components/chat/terminal-chat-new-input.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { MultilineTextEditorHandle } from "./multiline-editor";
|
||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
ResponseItem,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import MultilineTextEditor from "./multiline-editor";
|
||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
import Spinner from "../vendor/ink-spinner.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React, { useCallback, useState, Fragment } from "react";
|
||||
import { useInterval } from "use-interval";
|
||||
|
||||
const suggestions = [
|
||||
"explain this codebase to me",
|
||||
"fix any build errors",
|
||||
"are there any bugs in my code?",
|
||||
];
|
||||
|
||||
const typeHelpText = `ctrl+c to exit | "/clear" to reset context | "/help" for commands | ↑↓ to recall history | ctrl+x to open external editor | enter to send`;
|
||||
|
||||
// Enable verbose logging for the history‑navigation logic when the
|
||||
// DEBUG_TCI environment variable is truthy. The traces help while debugging
|
||||
// unit‑test failures but remain silent in production.
|
||||
const DEBUG_HIST =
|
||||
process.env["DEBUG_TCI"] === "1" || process.env["DEBUG_TCI"] === "true";
|
||||
|
||||
const thinkingTexts = ["Thinking"]; /* [
|
||||
"Consulting the rubber duck",
|
||||
"Maximizing paperclips",
|
||||
"Reticulating splines",
|
||||
"Immanentizing the Eschaton",
|
||||
"Thinking",
|
||||
"Thinking about thinking",
|
||||
"Spinning in circles",
|
||||
"Counting dust specks",
|
||||
"Updating priors",
|
||||
"Feeding the utility monster",
|
||||
"Taking off",
|
||||
"Wireheading",
|
||||
"Counting to infinity",
|
||||
"Staring into the Basilisk",
|
||||
"Running acausal tariff negotiations",
|
||||
"Searching the library of babel",
|
||||
"Multiplying matrices",
|
||||
"Solving the halting problem",
|
||||
"Counting grains of sand",
|
||||
"Simulating a simulation",
|
||||
"Asking the oracle",
|
||||
"Detangling qubits",
|
||||
"Reading tea leaves",
|
||||
"Pondering universal love and transcendant joy",
|
||||
"Feeling the AGI",
|
||||
"Shaving the yak",
|
||||
"Escaping local minima",
|
||||
"Pruning the search tree",
|
||||
"Descending the gradient",
|
||||
"Painting the bikeshed",
|
||||
"Securing funding",
|
||||
]; */
|
||||
|
||||
export default function TerminalChatInput({
|
||||
isNew: _isNew,
|
||||
loading,
|
||||
submitInput,
|
||||
confirmationPrompt,
|
||||
submitConfirmation,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
contextLeftPercent,
|
||||
openOverlay,
|
||||
openModelOverlay,
|
||||
openApprovalOverlay,
|
||||
openHelpOverlay,
|
||||
interruptAgent,
|
||||
active,
|
||||
}: {
|
||||
isNew: boolean;
|
||||
loading: boolean;
|
||||
submitInput: (input: Array<ResponseInputItem>) => void;
|
||||
confirmationPrompt: React.ReactNode | null;
|
||||
submitConfirmation: (
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) => void;
|
||||
setLastResponseId: (lastResponseId: string) => void;
|
||||
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
|
||||
contextLeftPercent: number;
|
||||
openOverlay: () => void;
|
||||
openModelOverlay: () => void;
|
||||
openApprovalOverlay: () => void;
|
||||
openHelpOverlay: () => void;
|
||||
interruptAgent: () => void;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const app = useApp();
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<Array<string>>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
// Multiline text editor is now the default input mode. We keep an
|
||||
// incremental `editorKey` so that we can force‑remount the component and
|
||||
// thus reset its internal buffer after each successful submit.
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
|
||||
// Imperative handle from the multiline editor so we can query caret position
|
||||
const editorRef = React.useRef<MultilineTextEditorHandle | null>(null);
|
||||
|
||||
// Track the caret row across keystrokes so we can tell whether the cursor
|
||||
// was *already* on the first/last line before the curren`t key event. This
|
||||
// lets us distinguish between a normal vertical navigation (e.g. moving
|
||||
// from row 1 → row 0 inside a multi‑line draft) and an attempt to navigate
|
||||
// the chat history (pressing ↑ again while already at row 0).
|
||||
const prevCursorRow = React.useRef<number | null>(null);
|
||||
|
||||
useInput(
|
||||
(_input, _key) => {
|
||||
if (!confirmationPrompt && !loading) {
|
||||
if (_key.upArrow) {
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] upArrow", {
|
||||
historyIndex,
|
||||
input,
|
||||
cursorRow: editorRef.current?.getRow?.(),
|
||||
});
|
||||
}
|
||||
// Only recall history when the caret was *already* on the very first
|
||||
// row *before* this key‑press. That means the user pressed ↑ while
|
||||
// the cursor sat at the top – mirroring how shells like Bash/zsh
|
||||
// enter history navigation. When the caret starts on a lower line
|
||||
// the first ↑ should merely move it up one row; only a subsequent
|
||||
// press (when we are *still* at row 0) should trigger the recall.
|
||||
|
||||
const cursorRow = editorRef.current?.getRow?.() ?? 0;
|
||||
const wasAtFirstRow = (prevCursorRow.current ?? cursorRow) === 0;
|
||||
|
||||
if (history.length > 0 && cursorRow === 0 && wasAtFirstRow) {
|
||||
if (historyIndex == null) {
|
||||
const currentDraft = editorRef.current?.getText?.() ?? input;
|
||||
setDraftInput(currentDraft);
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] store draft", JSON.stringify(currentDraft));
|
||||
}
|
||||
}
|
||||
|
||||
let newIndex: number;
|
||||
if (historyIndex == null) {
|
||||
newIndex = history.length - 1;
|
||||
} else {
|
||||
newIndex = Math.max(0, historyIndex - 1);
|
||||
}
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
// Re‑mount the editor so it picks up the new initialText.
|
||||
setEditorKey((k) => k + 1);
|
||||
return; // we handled the key
|
||||
}
|
||||
// Otherwise let the event propagate so the editor moves the caret.
|
||||
}
|
||||
|
||||
if (_key.downArrow) {
|
||||
if (DEBUG_HIST) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[TCI] downArrow", { historyIndex, draftInput, input });
|
||||
}
|
||||
// Only move forward in history when we're already *in* history mode
|
||||
// AND the caret sits on the last line of the buffer (so ↓ within a
|
||||
// multi‑line draft simply moves the caret down).
|
||||
if (historyIndex != null && editorRef.current?.isCursorAtLastRow()) {
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setInput(draftInput);
|
||||
setEditorKey((k) => k + 1);
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex] ?? "");
|
||||
setEditorKey((k) => k + 1);
|
||||
}
|
||||
return; // handled
|
||||
}
|
||||
// Otherwise let it propagate.
|
||||
}
|
||||
}
|
||||
|
||||
if (input.trim() === "") {
|
||||
if (_key.tab) {
|
||||
setSelectedSuggestion(
|
||||
(s) => (s + (_key.shift ? -1 : 1)) % (suggestions.length + 1),
|
||||
);
|
||||
} else if (selectedSuggestion && _key.return) {
|
||||
const suggestion = suggestions[selectedSuggestion - 1] || "";
|
||||
setInput("");
|
||||
setSelectedSuggestion(0);
|
||||
submitInput([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: suggestion }],
|
||||
type: "message",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (_input === "\u0003" || (_input === "c" && _key.ctrl)) {
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
}
|
||||
|
||||
// Update the cached cursor position *after* we've potentially handled
|
||||
// the key so that the next event has the correct "previous" reference.
|
||||
prevCursorRow.current = editorRef.current?.getRow?.() ?? null;
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const inputValue = value.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/history") {
|
||||
setInput("");
|
||||
openOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "/help") {
|
||||
setInput("");
|
||||
openHelpOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/model")) {
|
||||
setInput("");
|
||||
openModelOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.startsWith("/approval")) {
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === "q" || inputValue === ":q" || inputValue === "exit") {
|
||||
setInput("");
|
||||
// wait one 60ms frame
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
onExit();
|
||||
process.exit(0);
|
||||
}, 60);
|
||||
return;
|
||||
} else if (inputValue === "/clear" || inputValue === "clear") {
|
||||
setInput("");
|
||||
setSessionId("");
|
||||
setLastResponseId("");
|
||||
clearTerminal();
|
||||
|
||||
// Emit a system message to confirm the clear action. We *append*
|
||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `clear-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: "Context cleared" }],
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const images: Array<string> = [];
|
||||
const text = inputValue
|
||||
.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
const inputItem = await createInputItem(text, images);
|
||||
submitInput([inputItem]);
|
||||
setHistory((prev) => {
|
||||
if (prev[prev.length - 1] === value) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, value];
|
||||
});
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
setSelectedSuggestion(0);
|
||||
setInput("");
|
||||
},
|
||||
[
|
||||
setInput,
|
||||
submitInput,
|
||||
setLastResponseId,
|
||||
setItems,
|
||||
app,
|
||||
setHistory,
|
||||
setHistoryIndex,
|
||||
openOverlay,
|
||||
openApprovalOverlay,
|
||||
openModelOverlay,
|
||||
openHelpOverlay,
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmationPrompt) {
|
||||
return (
|
||||
<TerminalChatCommandReview
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
onReviewCommand={submitConfirmation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{loading ? (
|
||||
<Box borderStyle="round">
|
||||
<TerminalChatInputThinking
|
||||
onInterrupt={interruptAgent}
|
||||
active={active}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box borderStyle="round">
|
||||
<MultilineTextEditor
|
||||
ref={editorRef}
|
||||
onChange={(txt: string) => setInput(txt)}
|
||||
key={editorKey}
|
||||
initialText={input}
|
||||
height={8}
|
||||
focus={active}
|
||||
onSubmit={(txt) => {
|
||||
onSubmit(txt);
|
||||
|
||||
setEditorKey((k) => k + 1);
|
||||
|
||||
setInput("");
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<Text dimColor>
|
||||
{!input ? (
|
||||
<>
|
||||
try:{" "}
|
||||
{suggestions.map((m, key) => (
|
||||
<Fragment key={key}>
|
||||
{key !== 0 ? " | " : ""}
|
||||
<Text
|
||||
backgroundColor={
|
||||
key + 1 === selectedSuggestion ? "blackBright" : ""
|
||||
}
|
||||
>
|
||||
{m}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{typeHelpText}
|
||||
{contextLeftPercent < 25 && (
|
||||
<>
|
||||
{" — "}
|
||||
<Text color="red">
|
||||
{Math.round(contextLeftPercent)}% context left
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatInputThinking({
|
||||
onInterrupt,
|
||||
active,
|
||||
}: {
|
||||
onInterrupt: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
|
||||
|
||||
const [thinkingText] = useState(
|
||||
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Raw stdin listener to catch the case where the terminal delivers two
|
||||
// consecutive ESC bytes ("\x1B\x1B") in a *single* chunk. Ink's `useInput`
|
||||
// collapses that sequence into one key event, so the regular two‑step
|
||||
// handler above never sees the second press. By inspecting the raw data
|
||||
// we can identify this special case and trigger the interrupt while still
|
||||
// requiring a double press for the normal single‑byte ESC events.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure raw mode – already enabled by Ink when the component has focus,
|
||||
// but called defensively in case that assumption ever changes.
|
||||
setRawMode?.(true);
|
||||
|
||||
const onData = (data: Buffer | string) => {
|
||||
if (awaitingConfirm) {
|
||||
return; // already awaiting a second explicit press
|
||||
}
|
||||
|
||||
// Handle both Buffer and string forms.
|
||||
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
|
||||
if (str === "\x1b\x1b") {
|
||||
// Treat as the first Escape press – prompt the user for confirmation.
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"raw stdin: received collapsed ESC ESC – starting confirmation timer",
|
||||
);
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
stdin?.on("data", onData);
|
||||
|
||||
return () => {
|
||||
stdin?.off("data", onData);
|
||||
};
|
||||
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
|
||||
|
||||
useInterval(() => {
|
||||
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
|
||||
}, 500);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (awaitingConfirm) {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: second ESC detected – triggering onInterrupt()");
|
||||
}
|
||||
onInterrupt();
|
||||
setAwaitingConfirm(false);
|
||||
} else {
|
||||
if (isLoggingEnabled()) {
|
||||
log("useInput: first ESC detected – waiting for confirmation");
|
||||
}
|
||||
setAwaitingConfirm(true);
|
||||
setTimeout(() => setAwaitingConfirm(false), 1500);
|
||||
}
|
||||
},
|
||||
{ isActive: active },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{thinkingText}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
Normal file
61
codex-cli/src/components/chat/terminal-chat-past-rollout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { TerminalChatSession } from "../../utils/session.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item";
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export default function TerminalChatPastRollout({
|
||||
session,
|
||||
items,
|
||||
}: {
|
||||
session: TerminalChatSession;
|
||||
items: Array<ResponseItem>;
|
||||
}): React.ReactElement {
|
||||
const { version, id: sessionId, model } = session;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round" paddingX={1} width={64}>
|
||||
<Text>
|
||||
● OpenAI <Text bold>Codex</Text>{" "}
|
||||
<Text dimColor>
|
||||
(research preview) <Text color="blueBright">v{version}</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
width={64}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text>
|
||||
<Text color="magenta">●</Text> localhost{" "}
|
||||
<Text dimColor>· session:</Text>{" "}
|
||||
<Text color="magentaBright" dimColor>
|
||||
{sessionId}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> When / Who:{" "}
|
||||
<Text bold>
|
||||
{session.timestamp} <Text dimColor>/</Text> {session.user}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> model: <Text bold>{model}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{React.useMemo(
|
||||
() =>
|
||||
items.map((item, key) => (
|
||||
<TerminalChatResponseItem key={key} item={item} />
|
||||
)),
|
||||
[items],
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
259
codex-cli/src/components/chat/terminal-chat-response-item.tsx
Normal file
259
codex-cli/src/components/chat/terminal-chat-response-item.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import type { TerminalRendererOptions } from "marked-terminal";
|
||||
import type {
|
||||
ResponseFunctionToolCallItem,
|
||||
ResponseFunctionToolCallOutputItem,
|
||||
ResponseInputMessageItem,
|
||||
ResponseItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseReasoningItem,
|
||||
} from "openai/resources/responses/responses";
|
||||
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size";
|
||||
import { parseToolCall, parseToolCallOutput } from "../../utils/parsers";
|
||||
import chalk, { type ForegroundColorName } from "chalk";
|
||||
import { Box, Text } from "ink";
|
||||
import { parse, setOptions } from "marked";
|
||||
import TerminalRenderer from "marked-terminal";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export default function TerminalChatResponseItem({
|
||||
item,
|
||||
fullStdout = false,
|
||||
}: {
|
||||
item: ResponseItem;
|
||||
fullStdout?: boolean;
|
||||
}): React.ReactElement {
|
||||
switch (item.type) {
|
||||
case "message":
|
||||
return <TerminalChatResponseMessage message={item} />;
|
||||
case "function_call":
|
||||
return <TerminalChatResponseToolCall message={item} />;
|
||||
case "function_call_output":
|
||||
return (
|
||||
<TerminalChatResponseToolCallOutput
|
||||
message={item}
|
||||
fullStdout={fullStdout}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// @ts-expect-error `reasoning` is not in the responses API yet
|
||||
if (item.type === "reasoning") {
|
||||
return <TerminalChatResponseReasoning message={item} />;
|
||||
}
|
||||
|
||||
return <TerminalChatResponseGenericMessage message={item} />;
|
||||
}
|
||||
|
||||
// TODO: this should be part of `ResponseReasoningItem`. Also it doesn't work.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Guess how long the assistant spent "thinking" based on the combined length
|
||||
* of the reasoning summary. The calculation itself is fast, but wrapping it in
|
||||
* `useMemo` in the consuming component ensures it only runs when the
|
||||
* `summary` array actually changes.
|
||||
*/
|
||||
// TODO: use actual thinking time
|
||||
//
|
||||
// function guessThinkingTime(summary: Array<ResponseReasoningItem.Summary>) {
|
||||
// const totalTextLength = summary
|
||||
// .map((t) => t.text.length)
|
||||
// .reduce((a, b) => a + b, summary.length - 1);
|
||||
// return Math.max(1, Math.ceil(totalTextLength / 300));
|
||||
// }
|
||||
|
||||
export function TerminalChatResponseReasoning({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseReasoningItem & { duration_ms?: number };
|
||||
}): React.ReactElement | null {
|
||||
// prefer the real duration if present
|
||||
const thinkingTime = message.duration_ms
|
||||
? Math.round(message.duration_ms / 1000)
|
||||
: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(message.summary || [])
|
||||
.map((t) => t.text.length)
|
||||
.reduce((a, b) => a + b, 0) / 300,
|
||||
),
|
||||
);
|
||||
if (thinkingTime <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box gap={1} flexDirection="column">
|
||||
<Box gap={1}>
|
||||
<Text bold color="magenta">
|
||||
thinking
|
||||
</Text>
|
||||
<Text dimColor>for {thinkingTime}s</Text>
|
||||
</Box>
|
||||
{message.summary?.map((summary, key) => {
|
||||
const s = summary as { headline?: string; text: string };
|
||||
return (
|
||||
<Box key={key} flexDirection="column">
|
||||
{s.headline && <Text bold>{s.headline}</Text>}
|
||||
<Markdown>{s.text}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const colorsByRole: Record<string, ForegroundColorName> = {
|
||||
assistant: "magentaBright",
|
||||
user: "blueBright",
|
||||
};
|
||||
|
||||
function TerminalChatResponseMessage({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseInputMessageItem | ResponseOutputMessage;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={colorsByRole[message.role] || "gray"}>
|
||||
{message.role === "assistant" ? "codex" : message.role}
|
||||
</Text>
|
||||
<Markdown>
|
||||
{message.content
|
||||
.map(
|
||||
(c) =>
|
||||
c.type === "output_text"
|
||||
? c.text
|
||||
: c.type === "refusal"
|
||||
? c.refusal
|
||||
: c.type === "input_text"
|
||||
? c.text
|
||||
: c.type === "input_image"
|
||||
? "<Image>"
|
||||
: c.type === "input_file"
|
||||
? c.filename
|
||||
: "", // unknown content type
|
||||
)
|
||||
.join(" ")}
|
||||
</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatResponseToolCall({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallItem;
|
||||
}) {
|
||||
const details = parseToolCall(message);
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="magentaBright" bold>
|
||||
command
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {details?.cmdReadableText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalChatResponseToolCallOutput({
|
||||
message,
|
||||
fullStdout,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallOutputItem;
|
||||
fullStdout: boolean;
|
||||
}) {
|
||||
const { output, metadata } = parseToolCallOutput(message.output);
|
||||
const { exit_code, duration_seconds } = metadata;
|
||||
const metadataInfo = useMemo(
|
||||
() =>
|
||||
[
|
||||
typeof exit_code !== "undefined" ? `code: ${exit_code}` : "",
|
||||
typeof duration_seconds !== "undefined"
|
||||
? `duration: ${duration_seconds}s`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
[exit_code, duration_seconds],
|
||||
);
|
||||
let displayedContent = output;
|
||||
if (message.type === "function_call_output" && !fullStdout) {
|
||||
const lines = displayedContent.split("\n");
|
||||
if (lines.length > 4) {
|
||||
const head = lines.slice(0, 4);
|
||||
const remaining = lines.length - 4;
|
||||
displayedContent = [...head, `... (${remaining} more lines)`].join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Colorize diff output: lines starting with '-' in red, '+' in green.
|
||||
// This makes patches and other diff‑like stdout easier to read.
|
||||
// We exclude the typical diff file headers ('---', '+++') so they retain
|
||||
// the default color. This is a best‑effort heuristic and should be safe for
|
||||
// non‑diff output – only the very first character of a line is inspected.
|
||||
// -------------------------------------------------------------------------
|
||||
const colorizedContent = displayedContent
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith("+") && !line.startsWith("++")) {
|
||||
return chalk.green(line);
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("--")) {
|
||||
return chalk.red(line);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="magenta" bold>
|
||||
command.stdout{" "}
|
||||
<Text dimColor>{metadataInfo ? `(${metadataInfo})` : ""}</Text>
|
||||
</Text>
|
||||
<Text dimColor>{colorizedContent}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalChatResponseGenericMessage({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseItem;
|
||||
}): React.ReactElement {
|
||||
return <Text>{JSON.stringify(message, null, 2)}</Text>;
|
||||
}
|
||||
|
||||
export type MarkdownProps = TerminalRendererOptions & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
...options
|
||||
}: MarkdownProps): React.ReactElement {
|
||||
const size = useTerminalSize();
|
||||
|
||||
const rendered = React.useMemo(() => {
|
||||
// Configure marked for this specific render
|
||||
setOptions({
|
||||
// @ts-expect-error missing parser, space props
|
||||
renderer: new TerminalRenderer({ ...options, width: size.columns }),
|
||||
});
|
||||
const parsed = parse(children, { async: false }).trim();
|
||||
|
||||
// Remove the truncation logic
|
||||
return parsed;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is an object of primitives
|
||||
}, [children, size.columns, size.rows]);
|
||||
|
||||
return <Text>{rendered}</Text>;
|
||||
}
|
||||
106
codex-cli/src/components/chat/terminal-chat-tool-call-item.tsx
Normal file
106
codex-cli/src/components/chat/terminal-chat-tool-call-item.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { shortenPath } from "../../utils/short-path";
|
||||
import { parseApplyPatch } from "@lib/parse-apply-patch";
|
||||
import chalk from "chalk";
|
||||
import { Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export function TerminalChatToolCallCommand({
|
||||
commandForDisplay,
|
||||
}: {
|
||||
commandForDisplay: string;
|
||||
}): React.ReactElement {
|
||||
// -------------------------------------------------------------------------
|
||||
// Colorize diff output inside the command preview: we detect individual
|
||||
// lines that begin with '+' or '-' (excluding the typical diff headers like
|
||||
// '+++', '---', '++', '--') and apply green/red coloring. This mirrors
|
||||
// how Git shows diffs and makes the patch easier to review.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const colorizedCommand = commandForDisplay
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith("+") && !line.startsWith("++")) {
|
||||
return chalk.green(line);
|
||||
}
|
||||
if (line.startsWith("-") && !line.startsWith("--")) {
|
||||
return chalk.red(line);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text bold>Shell Command</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {colorizedCommand}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalChatToolCallApplyPatch({
|
||||
commandForDisplay,
|
||||
patch,
|
||||
}: {
|
||||
commandForDisplay: string;
|
||||
patch: string;
|
||||
}): React.ReactElement {
|
||||
const ops = React.useMemo(() => parseApplyPatch(patch), [patch]);
|
||||
const firstOp = ops?.[0];
|
||||
|
||||
const title = React.useMemo(() => {
|
||||
if (!firstOp) {
|
||||
return "";
|
||||
}
|
||||
return capitalize(firstOp.type);
|
||||
}, [firstOp]);
|
||||
|
||||
const filePath = React.useMemo(() => {
|
||||
if (!firstOp) {
|
||||
return "";
|
||||
}
|
||||
return shortenPath(firstOp.path || ".");
|
||||
}, [firstOp]);
|
||||
|
||||
if (ops == null) {
|
||||
return (
|
||||
<>
|
||||
<Text bold color="red">
|
||||
Invalid Patch
|
||||
</Text>
|
||||
<Text color="red" dimColor>
|
||||
The provided patch command is invalid.
|
||||
</Text>
|
||||
<Text dimColor>{commandForDisplay}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!firstOp) {
|
||||
return (
|
||||
<>
|
||||
<Text bold color="yellow">
|
||||
Empty Patch
|
||||
</Text>
|
||||
<Text color="yellow" dimColor>
|
||||
No operations found in the patch command.
|
||||
</Text>
|
||||
<Text dimColor>{commandForDisplay}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
<Text bold>{title}</Text> <Text dimColor>{filePath}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {commandForDisplay}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
113
codex-cli/src/components/chat/terminal-chat-utils.ts
Normal file
113
codex-cli/src/components/chat/terminal-chat-utils.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { approximateTokensUsed } from "../../utils/approximate-tokens-used.js";
|
||||
|
||||
/**
|
||||
* Type‑guard that narrows a {@link ResponseItem} to one that represents a
|
||||
* user‑authored message. The OpenAI SDK represents both input *and* output
|
||||
* messages with a discriminated union where:
|
||||
* • `type` is the string literal "message" and
|
||||
* • `role` is one of "user" | "assistant" | "system" | "developer".
|
||||
*
|
||||
* For the purposes of de‑duplication we only care about *user* messages so we
|
||||
* detect those here in a single, reusable helper.
|
||||
*/
|
||||
function isUserMessage(
|
||||
item: ResponseItem,
|
||||
): item is ResponseItem & { type: "message"; role: "user"; content: unknown } {
|
||||
return item.type === "message" && (item as { role?: string }).role === "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum context length (in tokens) for a given model.
|
||||
* These numbers are best‑effort guesses and provide a basis for UI percentages.
|
||||
*/
|
||||
export function maxTokensForModel(model: string): number {
|
||||
const lower = model.toLowerCase();
|
||||
if (lower.includes("32k")) {
|
||||
return 32000;
|
||||
}
|
||||
if (lower.includes("16k")) {
|
||||
return 16000;
|
||||
}
|
||||
if (lower.includes("8k")) {
|
||||
return 8000;
|
||||
}
|
||||
if (lower.includes("4k")) {
|
||||
return 4000;
|
||||
}
|
||||
// Default to 128k for newer long‑context models
|
||||
return 128000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage of tokens remaining in context for a model.
|
||||
*/
|
||||
export function calculateContextPercentRemaining(
|
||||
items: Array<ResponseItem>,
|
||||
model: string,
|
||||
): number {
|
||||
const used = approximateTokensUsed(items);
|
||||
const max = maxTokensForModel(model);
|
||||
const remaining = Math.max(0, max - used);
|
||||
return (remaining / max) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate the stream of {@link ResponseItem}s before they are persisted in
|
||||
* component state.
|
||||
*
|
||||
* Historically we used the (optional) {@code id} field returned by the
|
||||
* OpenAI streaming API as the primary key: the first occurrence of any given
|
||||
* {@code id} “won” and subsequent duplicates were dropped. In practice this
|
||||
* proved brittle because locally‑generated user messages don’t include an
|
||||
* {@code id}. The result was that if a user quickly pressed <Enter> twice the
|
||||
* exact same message would appear twice in the transcript.
|
||||
*
|
||||
* The new rules are therefore:
|
||||
* 1. If a {@link ResponseItem} has an {@code id} keep only the *first*
|
||||
* occurrence of that {@code id} (this retains the previous behaviour for
|
||||
* assistant / tool messages).
|
||||
* 2. Additionally, collapse *consecutive* user messages with identical
|
||||
* content. Two messages are considered identical when their serialized
|
||||
* {@code content} array matches exactly. We purposefully restrict this
|
||||
* to **adjacent** duplicates so that legitimately repeated questions at
|
||||
* a later point in the conversation are still shown.
|
||||
*/
|
||||
export function uniqueById(items: Array<ResponseItem>): Array<ResponseItem> {
|
||||
const seenIds = new Set<string>();
|
||||
const deduped: Array<ResponseItem> = [];
|
||||
|
||||
for (const item of items) {
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Rule #1 – de‑duplicate by id when present
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (typeof item.id === "string" && item.id.length > 0) {
|
||||
if (seenIds.has(item.id)) {
|
||||
continue; // skip duplicates
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Rule #2 – collapse consecutive identical user messages
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if (isUserMessage(item) && deduped.length > 0) {
|
||||
const prev = deduped[deduped.length - 1]!;
|
||||
|
||||
if (
|
||||
isUserMessage(prev) &&
|
||||
// Note: the `content` field is an array of message parts. Performing
|
||||
// a deep compare is over‑kill here; serialising to JSON is sufficient
|
||||
// (and fast for the tiny payloads involved).
|
||||
JSON.stringify(prev.content) === JSON.stringify(item.content)
|
||||
) {
|
||||
continue; // skip duplicate user message
|
||||
}
|
||||
}
|
||||
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
400
codex-cli/src/components/chat/terminal-chat.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { CommandConfirmation } from "../../utils/agent/agent-loop.js";
|
||||
import type { AppConfig } from "../../utils/config.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "@lib/approvals.js";
|
||||
import type { ColorName } from "chalk";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
import type { ReviewDecision } from "src/utils/agent/review.ts";
|
||||
|
||||
import TerminalChatInput from "./terminal-chat-input.js";
|
||||
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-item.js";
|
||||
import {
|
||||
calculateContextPercentRemaining,
|
||||
uniqueById,
|
||||
} from "./terminal-chat-utils.js";
|
||||
import TerminalMessageHistory from "./terminal-message-history.js";
|
||||
import { useConfirmation } from "../../hooks/use-confirmation.js";
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size.js";
|
||||
import { AgentLoop } from "../../utils/agent/agent-loop.js";
|
||||
import { log, isLoggingEnabled } from "../../utils/agent/log.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 HelpOverlay from "../help-overlay.js";
|
||||
import HistoryOverlay from "../history-overlay.js";
|
||||
import ModelOverlay from "../model-overlay.js";
|
||||
import { formatCommandForDisplay } from "@lib/format-command.js";
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { inspect } from "util";
|
||||
|
||||
type Props = {
|
||||
config: AppConfig;
|
||||
prompt?: string;
|
||||
imagePaths?: Array<string>;
|
||||
approvalPolicy: ApprovalPolicy;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
|
||||
"suggest": undefined,
|
||||
"auto-edit": "greenBright",
|
||||
"full-auto": "green",
|
||||
};
|
||||
|
||||
export default function TerminalChat({
|
||||
config,
|
||||
prompt: _initialPrompt,
|
||||
imagePaths: _initialImagePaths,
|
||||
approvalPolicy: initialApprovalPolicy,
|
||||
fullStdout,
|
||||
}: Props): React.ReactElement {
|
||||
const [model, setModel] = useState<string>(config.model);
|
||||
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<Array<ResponseItem>>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// Allow switching approval modes at runtime via an overlay.
|
||||
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>(
|
||||
initialApprovalPolicy,
|
||||
);
|
||||
const [thinkingSeconds, setThinkingSeconds] = useState(0);
|
||||
const { requestConfirmation, confirmationPrompt, submitConfirmation } =
|
||||
useConfirmation();
|
||||
const [overlayMode, setOverlayMode] = useState<
|
||||
"none" | "history" | "model" | "approval" | "help"
|
||||
>("none");
|
||||
|
||||
const [initialPrompt, setInitialPrompt] = useState(_initialPrompt);
|
||||
const [initialImagePaths, setInitialImagePaths] =
|
||||
useState(_initialImagePaths);
|
||||
|
||||
const PWD = React.useMemo(() => shortCwd(), []);
|
||||
|
||||
// Keep a single AgentLoop instance alive across renders;
|
||||
// recreate only when model/instructions/approvalPolicy change.
|
||||
const agentRef = React.useRef<AgentLoop>();
|
||||
const [, forceUpdate] = React.useReducer((c) => c + 1, 0); // trigger re‑render
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// DEBUG: log every render w/ key bits of state
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`render – agent? ${Boolean(agentRef.current)} loading=${loading} items=${
|
||||
items.length
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggingEnabled()) {
|
||||
log("creating NEW AgentLoop");
|
||||
log(
|
||||
`model=${model} instructions=${Boolean(
|
||||
config.instructions,
|
||||
)} approvalPolicy=${approvalPolicy}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tear down any existing loop before creating a new one
|
||||
agentRef.current?.terminate();
|
||||
|
||||
agentRef.current = new AgentLoop({
|
||||
model,
|
||||
config,
|
||||
instructions: config.instructions,
|
||||
approvalPolicy,
|
||||
onLastResponseId: setLastResponseId,
|
||||
onItem: (item) => {
|
||||
log(`onItem: ${JSON.stringify(item)}`);
|
||||
setItems((prev) => {
|
||||
const updated = uniqueById([...prev, item as ResponseItem]);
|
||||
saveRollout(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onLoading: setLoading,
|
||||
getCommandConfirmation: async (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
): Promise<CommandConfirmation> => {
|
||||
log(`getCommandConfirmation: ${command}`);
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
const { decision: review, customDenyMessage } =
|
||||
await requestConfirmation(
|
||||
<TerminalChatToolCallCommand
|
||||
commandForDisplay={commandForDisplay}
|
||||
/>,
|
||||
);
|
||||
return { review, customDenyMessage, applyPatch };
|
||||
},
|
||||
});
|
||||
|
||||
// force a render so JSX below can "see" the freshly created agent
|
||||
forceUpdate();
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(`AgentLoop created: ${inspect(agentRef.current, { depth: 1 })}`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isLoggingEnabled()) {
|
||||
log("terminating AgentLoop");
|
||||
}
|
||||
agentRef.current?.terminate();
|
||||
agentRef.current = undefined;
|
||||
forceUpdate(); // re‑render after teardown too
|
||||
};
|
||||
}, [model, config, approvalPolicy, requestConfirmation]);
|
||||
|
||||
// whenever loading starts/stops, reset or start a timer — but pause the
|
||||
// timer while a confirmation overlay is displayed so we don't trigger a
|
||||
// re‑render every second during apply_patch reviews.
|
||||
useEffect(() => {
|
||||
let handle: ReturnType<typeof setInterval> | null = null;
|
||||
// Only tick the "thinking…" timer when the agent is actually processing
|
||||
// a request *and* the user is not being asked to review a command.
|
||||
if (loading && confirmationPrompt == null) {
|
||||
setThinkingSeconds(0);
|
||||
handle = setInterval(() => {
|
||||
setThinkingSeconds((s) => s + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
}
|
||||
setThinkingSeconds(0);
|
||||
}
|
||||
return () => {
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
}
|
||||
};
|
||||
}, [loading, confirmationPrompt]);
|
||||
|
||||
// Let's also track whenever the ref becomes available
|
||||
const agent = agentRef.current;
|
||||
useEffect(() => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(`agentRef.current is now ${Boolean(agent)}`);
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Dynamic layout constraints – keep total rendered rows <= terminal rows
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const { rows: terminalRows } = useTerminalSize();
|
||||
|
||||
useEffect(() => {
|
||||
const processInitialInputItems = async () => {
|
||||
if (
|
||||
(!initialPrompt || initialPrompt.trim() === "") &&
|
||||
(!initialImagePaths || initialImagePaths.length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const inputItems = [
|
||||
await createInputItem(initialPrompt || "", initialImagePaths || []),
|
||||
];
|
||||
// Clear them to prevent subsequent runs
|
||||
setInitialPrompt("");
|
||||
setInitialImagePaths([]);
|
||||
agent?.run(inputItems);
|
||||
};
|
||||
processInitialInputItems();
|
||||
}, [agent, initialPrompt, initialImagePaths]);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// In-app warning if CLI --model isn't in fetched list
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const available = await getAvailableModels();
|
||||
if (model && available.length > 0 && !available.includes(model)) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `unknown-model-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Warning: model "${model}" is not in the list of available models returned by OpenAI.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
// run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Just render every item in order, no grouping/collapse
|
||||
const lastMessageBatch = items.map((item) => ({ item }));
|
||||
const groupCounts: Record<string, number> = {};
|
||||
const userMsgCount = items.filter(
|
||||
(i) => i.type === "message" && i.role === "user",
|
||||
).length;
|
||||
|
||||
const contextLeftPercent = useMemo(
|
||||
() => calculateContextPercentRemaining(items, model),
|
||||
[items, model],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{agent ? (
|
||||
<TerminalMessageHistory
|
||||
batch={lastMessageBatch}
|
||||
groupCounts={groupCounts}
|
||||
items={items}
|
||||
userMsgCount={userMsgCount}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
loading={loading}
|
||||
thinkingSeconds={thinkingSeconds}
|
||||
fullStdout={fullStdout}
|
||||
headerProps={{
|
||||
terminalRows,
|
||||
version: CLI_VERSION,
|
||||
PWD,
|
||||
model,
|
||||
approvalPolicy,
|
||||
colorsByPolicy,
|
||||
agent,
|
||||
initialImagePaths,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Initializing agent…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{agent && (
|
||||
<TerminalChatInput
|
||||
loading={loading}
|
||||
setItems={setItems}
|
||||
isNew={Boolean(items.length === 0)}
|
||||
setLastResponseId={setLastResponseId}
|
||||
confirmationPrompt={confirmationPrompt}
|
||||
submitConfirmation={(
|
||||
decision: ReviewDecision,
|
||||
customDenyMessage?: string,
|
||||
) =>
|
||||
submitConfirmation({
|
||||
decision,
|
||||
customDenyMessage,
|
||||
})
|
||||
}
|
||||
contextLeftPercent={contextLeftPercent}
|
||||
openOverlay={() => setOverlayMode("history")}
|
||||
openModelOverlay={() => setOverlayMode("model")}
|
||||
openApprovalOverlay={() => setOverlayMode("approval")}
|
||||
openHelpOverlay={() => setOverlayMode("help")}
|
||||
active={overlayMode === "none"}
|
||||
interruptAgent={() => {
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"TerminalChat: interruptAgent invoked – calling agent.cancel()",
|
||||
);
|
||||
}
|
||||
agent.cancel();
|
||||
setLoading(false);
|
||||
}}
|
||||
submitInput={(inputs) => {
|
||||
agent.run(inputs, lastResponseId || "");
|
||||
return {};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{overlayMode === "history" && (
|
||||
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
{overlayMode === "model" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
hasLastResponse={Boolean(lastResponseId)}
|
||||
onSelect={(newModel) => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
"TerminalChat: interruptAgent invoked – calling agent.cancel()",
|
||||
);
|
||||
if (!agent) {
|
||||
log("TerminalChat: agent is not ready yet");
|
||||
}
|
||||
}
|
||||
agent?.cancel();
|
||||
setLoading(false);
|
||||
|
||||
setModel(newModel);
|
||||
setLastResponseId((prev) =>
|
||||
prev && newModel !== model ? null : prev,
|
||||
);
|
||||
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `switch-model-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Switched model to ${newModel}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
setOverlayMode("none");
|
||||
}}
|
||||
onExit={() => setOverlayMode("none")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayMode === "approval" && (
|
||||
<ApprovalModeOverlay
|
||||
currentMode={approvalPolicy}
|
||||
onSelect={(newMode) => {
|
||||
agent?.cancel();
|
||||
setLoading(false);
|
||||
if (newMode === approvalPolicy) {
|
||||
return;
|
||||
}
|
||||
setApprovalPolicy(newMode as ApprovalPolicy);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `switch-approval-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Switched approval mode to ${newMode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
setOverlayMode("none");
|
||||
}}
|
||||
onExit={() => setOverlayMode("none")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayMode === "help" && (
|
||||
<HelpOverlay onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
84
codex-cli/src/components/chat/terminal-header.tsx
Normal file
84
codex-cli/src/components/chat/terminal-header.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { AgentLoop } from "../../utils/agent/agent-loop.js";
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import path from "node:path";
|
||||
import React from "react";
|
||||
|
||||
export interface TerminalHeaderProps {
|
||||
terminalRows: number;
|
||||
version: string;
|
||||
PWD: string;
|
||||
model: string;
|
||||
approvalPolicy: string;
|
||||
colorsByPolicy: Record<string, string | undefined>;
|
||||
agent?: AgentLoop;
|
||||
initialImagePaths?: Array<string>;
|
||||
}
|
||||
|
||||
const TerminalHeader: React.FC<TerminalHeaderProps> = ({
|
||||
terminalRows,
|
||||
version,
|
||||
PWD,
|
||||
model,
|
||||
approvalPolicy,
|
||||
colorsByPolicy,
|
||||
agent,
|
||||
initialImagePaths,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{terminalRows < 10 ? (
|
||||
// Compact header for small terminal windows
|
||||
<Text>
|
||||
● Codex v{version} – {PWD} – {model} –{" "}
|
||||
<Text color={colorsByPolicy[approvalPolicy]}>{approvalPolicy}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Box borderStyle="round" paddingX={1} width={64}>
|
||||
<Text>
|
||||
● OpenAI <Text bold>Codex</Text>{" "}
|
||||
<Text dimColor>
|
||||
(research preview) <Text color="blueBright">v{version}</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
width={64}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text>
|
||||
localhost <Text dimColor>session:</Text>{" "}
|
||||
<Text color="magentaBright" dimColor>
|
||||
{agent?.sessionId ?? "<no-session>"}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> workdir: <Text bold>{PWD}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> model: <Text bold>{model}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> approval:{" "}
|
||||
<Text bold color={colorsByPolicy[approvalPolicy]} dimColor>
|
||||
{approvalPolicy}
|
||||
</Text>
|
||||
</Text>
|
||||
{initialImagePaths?.map((img, idx) => (
|
||||
<Text key={img ?? idx} color="gray">
|
||||
<Text color="blueBright">↳</Text> image:{" "}
|
||||
<Text bold>{path.basename(img)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalHeader;
|
||||
76
codex-cli/src/components/chat/terminal-message-history.tsx
Normal file
76
codex-cli/src/components/chat/terminal-message-history.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { TerminalHeaderProps } from "./terminal-header.js";
|
||||
import type { GroupedResponseItem } from "./use-message-grouping.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
import { Box, Static, Text } from "ink";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
// A batch entry can either be a standalone response item or a grouped set of
|
||||
// items (e.g. auto‑approved tool‑call batches) that should be rendered
|
||||
// together.
|
||||
type BatchEntry = { item?: ResponseItem; group?: GroupedResponseItem };
|
||||
type MessageHistoryProps = {
|
||||
batch: Array<BatchEntry>;
|
||||
groupCounts: Record<string, number>;
|
||||
items: Array<ResponseItem>;
|
||||
userMsgCount: number;
|
||||
confirmationPrompt: React.ReactNode;
|
||||
loading: boolean;
|
||||
thinkingSeconds: number;
|
||||
headerProps: TerminalHeaderProps;
|
||||
fullStdout: boolean;
|
||||
};
|
||||
|
||||
const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
batch,
|
||||
headerProps,
|
||||
loading,
|
||||
thinkingSeconds,
|
||||
fullStdout,
|
||||
}) => {
|
||||
const [messages, debug] = useMemo(
|
||||
() => [batch.map(({ item }) => item!), process.env["DEBUG"]],
|
||||
[batch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{loading && debug && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">{`(${thinkingSeconds}s)`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Static items={["header", ...messages]}>
|
||||
{(item, index) => {
|
||||
if (item === "header") {
|
||||
return <TerminalHeader key="header" {...headerProps} />;
|
||||
}
|
||||
|
||||
// After the guard above `item` can only be a ResponseItem.
|
||||
const message = item as ResponseItem;
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
marginLeft={
|
||||
message.type === "message" && message.role === "user" ? 0 : 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fullStdout={fullStdout}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Static>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHistory);
|
||||
81
codex-cli/src/components/chat/use-message-grouping.ts
Normal file
81
codex-cli/src/components/chat/use-message-grouping.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { parseToolCall } from "../../utils/parsers.js";
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Represents a grouped sequence of response items (e.g., function call batches).
|
||||
*/
|
||||
export type GroupedResponseItem = {
|
||||
label: string;
|
||||
items: Array<ResponseItem>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to group recent response items for display batching.
|
||||
* Returns counts of auto-approved tool call groups, the latest batch,
|
||||
* and the count of user messages in the visible window.
|
||||
*/
|
||||
export function useMessageGrouping(visibleItems: Array<ResponseItem>): {
|
||||
groupCounts: Record<string, number>;
|
||||
batch: Array<{ item?: ResponseItem; group?: GroupedResponseItem }>;
|
||||
userMsgCount: number;
|
||||
} {
|
||||
return useMemo(() => {
|
||||
// The grouping logic only depends on the subset of messages that are
|
||||
// currently rendered (visibleItems). Using that as the sole dependency
|
||||
// keeps recomputations to a minimum and avoids unnecessary work when the
|
||||
// full list of `items` changes outside of the visible window.
|
||||
let userMsgCount = 0;
|
||||
const groupCounts: Record<string, number> = {};
|
||||
visibleItems.forEach((m) => {
|
||||
if (m.type === "function_call") {
|
||||
const toolCall = parseToolCall(m);
|
||||
if (toolCall?.autoApproval) {
|
||||
const group = toolCall.autoApproval.group;
|
||||
groupCounts[group] = (groupCounts[group] || 0) + 1;
|
||||
}
|
||||
}
|
||||
if (m.type === "message" && m.role === "user") {
|
||||
userMsgCount++;
|
||||
}
|
||||
});
|
||||
const lastFew = visibleItems.slice(-3);
|
||||
const batch: Array<{ item?: ResponseItem; group?: GroupedResponseItem }> =
|
||||
[];
|
||||
if (lastFew[0]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[0]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: lastFew,
|
||||
},
|
||||
});
|
||||
if (lastFew[2]?.type === "message") {
|
||||
batch.push({ item: lastFew[2] });
|
||||
}
|
||||
} else if (lastFew[1]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[1]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: lastFew.slice(1),
|
||||
},
|
||||
});
|
||||
} else if (lastFew[2]?.type === "function_call") {
|
||||
const toolCall = parseToolCall(lastFew[2]);
|
||||
batch.push({
|
||||
group: {
|
||||
label: toolCall?.autoApproval?.group || "Running command",
|
||||
items: [lastFew[2]],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
lastFew.forEach((item) => batch.push({ item }));
|
||||
}
|
||||
return { groupCounts, batch, userMsgCount };
|
||||
// `items` is stable across renders while `visibleItems` changes based on
|
||||
// the scroll window. Including only `visibleItems` avoids unnecessary
|
||||
// recomputations while still producing correct results.
|
||||
}, [visibleItems]);
|
||||
}
|
||||
Reference in New Issue
Block a user