Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# To do list with go here
731 changes: 9 additions & 722 deletions codex-cli/README.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions codex-cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { initLogger } from "./utils/logger/log";
import { isModelSupportedForResponses } from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { providers } from "./utils/providers";
import { onExit, setInkRenderer } from "./utils/terminal";
import { clearTerminal, onExit, setInkRenderer } from "./utils/terminal";
import chalk from "chalk";
import { spawnSync } from "child_process";
import fs from "fs";
Expand Down Expand Up @@ -75,7 +75,7 @@ const cli = meow(
--version Print version and exit

-h, --help Show usage and exit
-m, --model <model> Model to use for completions (default: codex-mini-latest)
-m, --model <model> Model to use for completions (default: gpt-5)
-p, --provider <provider> Provider to use for completions (default: openai)
-i, --image <path> Path(s) to image files to include as input
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
Expand Down Expand Up @@ -596,6 +596,13 @@ const approvalPolicy: ApprovalPolicy =
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;

// Ensure the terminal starts at the top of the viewport on initial load.
// This clears any existing content and scrollback, then positions the cursor
// at the home position before Ink begins rendering.
if (process.stdout.isTTY && process.env["CODEX_QUIET_MODE"] !== "1") {
clearTerminal();
}

const instance = render(
<App
prompt={prompt}
Expand Down
17 changes: 17 additions & 0 deletions codex-cli/src/components/animations/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const Onboarding = ` ●●● ●●●●●●●●●●●●●
●●●●●●●● ●●●●●●●●●●●●●•
●●●● ●●●●●● ●·········●●●•
●●●● ●●●●● ●·········●●●•
●●●●●●● ●●● ●·········●●●•
●●●●●●●●● ● ●·········●●●•
●●●●●●●●● ●● ●●●●●●●●●●●●●•
●●●●●●●●●●● ●●● ●●●●●●●●●●●●●••
●●●●●●●●●●● ●●● ●● ●● ●● ●●●●●●●●●●●●●••·
●●●●●●●●●●● ●●●● ●● ●● ●● ●●●●●●●●●●●●•●•·
●●●●●●●●●●●●●●●● ●●●●●●●●●●●●●●●●•·
●●●●●●●●●●●●●●●● ●●●●●●●●●●●●●●●●•·
●●●●●●●●●●●●● ●●●●●●●●●•••••●●•·
●●●●●●●●●●●● ●●•••●●●●●●●●●●●•·
●●●●●●●●●●● ●●•●•●•●•●·●·●·•••
●●●●●●●●● ●•·•·•●•·•·•·•●●•
●●●●●●● ●●●●●●●●●●●●●●●●● `;
1 change: 1 addition & 0 deletions codex-cli/src/components/chat/animation-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ANIMATION_CYCLE_MS = 1200; // default ~1.6s per cycle
8 changes: 8 additions & 0 deletions codex-cli/src/components/chat/multiline-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ const MultilineTextEditorInner = (
console.log("[MultilineTextEditor] event", { input, key });
}

// Fast-path: let Cmd+Enter (meta+return) insert a newline.
// Some terminals map the Command key to `meta` in Ink's key object.
if (key.return && key.meta) {
buffer.current.newline();
setVersion((v) => v + 1);
return;
}

// 1a) CSI-u / modifyOtherKeys *mode 2* (Ink strips initial ESC, so we
// start with '[') – format: "[<code>;<modifiers>u".
if (input.startsWith("[") && input.endsWith("u")) {
Expand Down
165 changes: 90 additions & 75 deletions codex-cli/src/components/chat/terminal-chat-input-thinking.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { log } from "../../utils/logger/log.js";
import { Box, Text, useInput, useStdin } from "ink";
import React, { useState } from "react";
import { useInterval } from "use-interval";
import { Box, Text, useInput } from "ink";
import React, { useEffect, useMemo, useState } from "react";
import { ANIMATION_CYCLE_MS } from "./animation-config";

// Retaining a single static placeholder text for potential future use. The
// more elaborate randomised thinking prompts were removed to streamline the
Expand All @@ -10,51 +10,35 @@ import { useInterval } from "use-interval";
export default function TerminalChatInputThinking({
onInterrupt,
active,
thinkingSeconds,
thinkingSeconds: _thinkingSeconds,
title,
}: {
onInterrupt: () => void;
active: boolean;
thinkingSeconds: number;
title?: string;
}): React.ReactElement {
const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
const [persistentTitle, setPersistentTitle] = useState<string>("");
const [phase, setPhase] = useState<number>(0);

// Animate the ellipsis
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Avoid forcing raw-mode globally; rely on Ink's useInput handling with isActive

const { stdin, setRawMode } = useStdin();
// No timers required beyond tracking the elapsed seconds supplied via props.

React.useEffect(() => {
if (!active) {
return;
// Keep last non-empty, non-default (not "Thinking") title visible until a new one arrives
useEffect(() => {
const incoming = typeof title === "string" ? title.trim() : "";
const isDefaultThinking = /^thinking$/i.test(incoming);
if (
incoming.length > 0 &&
!isDefaultThinking &&
incoming !== persistentTitle
) {
setPersistentTitle(incoming);
setPhase(0);
}

setRawMode?.(true);

const onData = (data: Buffer | string) => {
if (awaitingConfirm) {
return;
}

const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
if (str === "\x1b\x1b") {
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]);

// No timers required beyond tracking the elapsed seconds supplied via props.
}, [title, persistentTitle]);

useInput(
(_input, key) => {
Expand All @@ -75,48 +59,79 @@ export default function TerminalChatInputThinking({
{ isActive: active },
);

// Custom ball animation including the elapsed seconds
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];

const [frame, setFrame] = useState(0);

useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
// Animate a sliding shimmer over the title using levels 0..3 (white→dark gray)
const animatedNodes = useMemo(() => {
const text = (persistentTitle || "Thinking").split("");
const n = text.length;
// More ranges with darkest in the middle (peak)
// Intensities 0 (white) .. 8 (darkest)
const kernel = [
1,
2,
3,
4,
5,
6,
7,
8, // ramp up to darkest
8,
7,
6,
5,
4,
3,
2,
1, // ramp down symmetrically
];
const levels = new Array<number>(n).fill(0);
const center = phase % Math.max(1, n); // center position moves across text
const half = Math.floor(kernel.length / 2);
for (let k = 0; k < kernel.length; k += 1) {
const idx = (center - half + k + n) % n; // wrap kernel around text
levels[idx] = kernel[k] ?? 0;
}

// Preserve the spinner (ball) animation while keeping the elapsed seconds
// text static. We achieve this by rendering the bouncing ball inside the
// parentheses and appending the seconds counter *after* the spinner rather
// than injecting it directly next to the ball (which caused the counter to
// move horizontally together with the ball).
// Palette from white (0) to very dark gray (8)
const palette = [
"#FFFFFF", // 0
"#EDEDED", // 1
"#DBDBDB", // 2
"#C9C9C9", // 3
"#B7B7B7", // 4
"#A5A5A5", // 5
"#8F8F8F", // 6
"#6F6F6F", // 7
"#4A4A4A", // 8 darkest
];

return text.map((ch, i) => {
const lvl = levels[i] ?? 0;
const color = palette[Math.max(0, Math.min(palette.length - 1, lvl))];
return (
<Text key={i} color={color}>
{ch}
</Text>
);
});
}, [persistentTitle, phase]);

const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = `${frameTemplate} ${thinkingSeconds}s`;
useEffect(() => {
if (!active) {
return;
}
const textLen = (persistentTitle || "Thinking").length;
const cycle = Math.max(1, textLen); // number of positions for a full pass
const frameMs = Math.max(16, Math.round(ANIMATION_CYCLE_MS / cycle));
const id = setInterval(() => {
setPhase((p) => (p + 1) % cycle);
}, frameMs);
return () => clearInterval(id);
}, [active, persistentTitle]);

return (
<Box flexDirection="column" gap={1}>
<Box justifyContent="space-between">
<Box gap={2}>
<Text>{frameWithSeconds}</Text>
<Text>
Thinking
{dots}
</Text>
</Box>
<Text>
Press <Text bold>Esc</Text> twice to interrupt
</Text>
<Box>
<Text>{animatedNodes}</Text>
</Box>
{awaitingConfirm && (
<Text dimColor>
Expand Down
Loading