diff --git a/codex-cli/src/approvals.ts b/codex-cli/src/approvals.ts index 3b3162af..b9eb50d1 100644 --- a/codex-cli/src/approvals.ts +++ b/codex-cli/src/approvals.ts @@ -136,8 +136,8 @@ export function canAutoApprove( // bashCmd could be a mix of strings and operators, e.g.: // "ls || (true && pwd)" => [ 'ls', { op: '||' }, '(', 'true', { op: '&&' }, 'pwd', ')' ] // We try to ensure that *every* command segment is deemed safe and that - // all operators belong to an allow‑list. If so, the entire expression is - // considered auto‑approvable. + // all operators belong to an allow-list. If so, the entire expression is + // considered auto-approvable. const shellSafe = isEntireShellExpressionSafe(bashCmd); if (shellSafe != null) { @@ -333,7 +333,7 @@ export function isSafeCommand( }; case "true": return { - reason: "No‑op (true)", + reason: "No-op (true)", group: "Utility", }; case "echo": @@ -442,10 +442,10 @@ function isValidSedNArg(arg: string | undefined): boolean { // ---------------- Helper utilities for complex shell expressions ----------------- -// A conservative allow‑list of bash operators that do not, on their own, cause +// A conservative allow-list of bash operators that do not, on their own, cause // side effects. Redirections (>, >>, <, etc.) and command substitution `$()` // are intentionally excluded. Parentheses used for grouping are treated as -// strings by `shell‑quote`, so we do not add them here. Reference: +// strings by `shell-quote`, so we do not add them here. Reference: // https://github.com/substack/node-shell-quote#parsecmd-opts const SAFE_SHELL_OPERATORS: ReadonlySet = new Set([ "&&", // logical AND @@ -471,7 +471,7 @@ function isEntireShellExpressionSafe( } try { - // Collect command segments delimited by operators. `shell‑quote` represents + // Collect command segments delimited by operators. `shell-quote` represents // subshell grouping parentheses as literal strings "(" and ")"; treat them // as unsafe to keep the logic simple (since subshells could introduce // unexpected scope changes). @@ -539,7 +539,7 @@ function isParseEntryWithOp( return ( typeof entry === "object" && entry != null && - // Using the safe `in` operator keeps the check property‑safe even when + // Using the safe `in` operator keeps the check property-safe even when // `entry` is a `string`. "op" in entry && typeof (entry as { op?: unknown }).op === "string" diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index e6bed134..2ec883d4 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -136,7 +136,7 @@ const cli = meow( }, noProjectDoc: { type: "boolean", - description: "Disable automatic inclusion of project‑level codex.md", + description: "Disable automatic inclusion of project-level codex.md", }, projectDoc: { type: "string", @@ -202,19 +202,20 @@ complete -c codex -a '(_fish_complete_path)' -d 'file path'`, console.log(script); process.exit(0); } -// Show help if requested + +// For --help, show help and exit. if (cli.flags.help) { cli.showHelp(); } -// Handle config flag: open instructions file in editor and exit +// For --config, open custom instructions file in editor and exit. if (cli.flags.config) { - // Ensure configuration and instructions file exist try { - loadConfig(); + loadConfig(); // Ensures the file is created if it doesn't already exit. } catch { // ignore errors } + const filePath = INSTRUCTIONS_FILEPATH; const editor = process.env["EDITOR"] || (process.platform === "win32" ? "notepad" : "vi"); @@ -237,13 +238,13 @@ let config = loadConfig(undefined, undefined, { const prompt = cli.input[0]; const model = cli.flags.model ?? config.model; const imagePaths = cli.flags.image; -const provider = cli.flags.provider ?? config.provider; +const provider = cli.flags.provider ?? config.provider ?? "openai"; const apiKey = getApiKey(provider); if (!apiKey) { // eslint-disable-next-line no-console console.error( - `\n${chalk.red("Missing OpenAI API key.")}\n\n` + + `\n${chalk.red(`Missing ${provider} API key.`)}\n\n` + `Set the environment variable ${chalk.bold("OPENAI_API_KEY")} ` + `and re-run this command.\n` + `You can create a key here: ${chalk.bold( @@ -262,13 +263,11 @@ config = { provider, }; -// Check for updates after loading config -// This is important because we write state file in the config dir +// Check for updates after loading config. This is important because we write state file in +// the config dir. await checkForUpdates().catch(); -// --------------------------------------------------------------------------- -// --flex-mode validation (only allowed for o3 and o4-mini) -// --------------------------------------------------------------------------- +// For --flex-mode, validate and exit if incorrect. if (cli.flags.flexMode) { const allowedFlexModels = new Set(["o3", "o4-mini"]); if (!allowedFlexModels.has(config.model)) { @@ -282,13 +281,13 @@ if (cli.flags.flexMode) { } if ( - !(await isModelSupportedForResponses(config.model)) && + !(await isModelSupportedForResponses(provider, config.model)) && (!provider || provider.toLowerCase() === "openai") ) { // eslint-disable-next-line no-console console.error( `The model "${config.model}" does not appear in the list of models ` + - `available to your account. Double‑check the spelling (use\n` + + `available to your account. Double-check the spelling (use\n` + ` openai models list\n` + `to see the full list) or choose another model with the --model flag.`, ); @@ -297,6 +296,7 @@ if ( let rollout: AppRollout | undefined; +// For --view, optionally load an existing rollout from disk, display it and exit. if (cli.flags.view) { const viewPath = cli.flags.view; const absolutePath = path.isAbsolute(viewPath) @@ -312,7 +312,7 @@ if (cli.flags.view) { } } -// If we are running in --fullcontext mode, do that and exit. +// For --fullcontext, run the separate cli entrypoint and exit. if (fullContextMode) { await runSinglePass({ originalPrompt: prompt, @@ -328,11 +328,8 @@ const additionalWritableRoots: ReadonlyArray = ( cli.flags.writableRoot ?? [] ).map((p) => path.resolve(p)); -// If we are running in --quiet mode, do that and exit. -const quietMode = Boolean(cli.flags.quiet); -const fullStdout = Boolean(cli.flags.fullStdout); - -if (quietMode) { +// For --quiet, run the cli without user interactions and exit. +if (cli.flags.quiet) { process.env["CODEX_QUIET_MODE"] = "1"; if (!prompt || prompt.trim() === "") { // eslint-disable-next-line no-console @@ -389,7 +386,7 @@ const instance = render( imagePaths={imagePaths} approvalPolicy={approvalPolicy} additionalWritableRoots={additionalWritableRoots} - fullStdout={fullStdout} + fullStdout={Boolean(cli.flags.fullStdout)} />, { patchConsole: process.env["DEBUG"] ? false : true, @@ -501,13 +498,13 @@ process.on("SIGQUIT", exit); process.on("SIGTERM", exit); // --------------------------------------------------------------------------- -// Fallback for Ctrl‑C when stdin is in raw‑mode +// Fallback for Ctrl-C when stdin is in raw-mode // --------------------------------------------------------------------------- if (process.stdin.isTTY) { // Ensure we do not leave the terminal in raw mode if the user presses - // Ctrl‑C while some other component has focus and Ink is intercepting - // input. Node does *not* emit a SIGINT in raw‑mode, so we listen for the + // Ctrl-C while some other component has focus and Ink is intercepting + // input. Node does *not* emit a SIGINT in raw-mode, so we listen for the // corresponding byte (0x03) ourselves and trigger a graceful shutdown. const onRawData = (data: Buffer | string): void => { const str = Buffer.isBuffer(data) ? data.toString("utf8") : data; @@ -518,6 +515,6 @@ if (process.stdin.isTTY) { process.stdin.on("data", onRawData); } -// Ensure terminal clean‑up always runs, even when other code calls +// Ensure terminal clean-up always runs, even when other code calls // `process.exit()` directly. process.once("exit", onExit); diff --git a/codex-cli/src/components/chat/multiline-editor.tsx b/codex-cli/src/components/chat/multiline-editor.tsx index a91eceea..eea5ec48 100644 --- a/codex-cli/src/components/chat/multiline-editor.tsx +++ b/codex-cli/src/components/chat/multiline-editor.tsx @@ -14,7 +14,7 @@ import React, { useRef, useState } from "react"; * 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 + * mode, so make them harmless no-ops when they're absent to avoid runtime * failures during unit tests. * ----------------------------------------------------------------------- */ diff --git a/codex-cli/src/components/chat/terminal-chat-utils.ts b/codex-cli/src/components/chat/terminal-chat-utils.ts deleted file mode 100644 index 73ab3c97..00000000 --- a/codex-cli/src/components/chat/terminal-chat-utils.ts +++ /dev/null @@ -1,113 +0,0 @@ -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, - 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 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): Array { - const seenIds = new Set(); - const deduped: Array = []; - - 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; -} diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx index e20097eb..85582080 100644 --- a/codex-cli/src/components/chat/terminal-chat.tsx +++ b/codex-cli/src/components/chat/terminal-chat.tsx @@ -6,10 +6,6 @@ import type { ResponseItem } from "openai/resources/responses/responses.mjs"; import TerminalChatInput from "./terminal-chat-input.js"; import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-command.js"; -import { - calculateContextPercentRemaining, - uniqueById, -} from "./terminal-chat-utils.js"; import TerminalMessageHistory from "./terminal-message-history.js"; import { formatCommandForDisplay } from "../../format-command.js"; import { useConfirmation } from "../../hooks/use-confirmation.js"; @@ -22,7 +18,11 @@ import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/ext import { getGitDiff } from "../../utils/get-diff.js"; import { createInputItem } from "../../utils/input-utils.js"; import { log } from "../../utils/logger/log.js"; -import { getAvailableModels } from "../../utils/model-utils.js"; +import { + getAvailableModels, + calculateContextPercentRemaining, + uniqueById, +} 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"; @@ -106,11 +106,8 @@ async function generateCommandExplanation( } catch (error) { log(`Error generating command explanation: ${error}`); - // Improved error handling with more specific error information let errorMessage = "Unable to generate explanation due to an error."; - if (error instanceof Error) { - // Include specific error message for better debugging errorMessage = `Unable to generate explanation: ${error.message}`; // If it's an API error, check for more specific information @@ -141,18 +138,17 @@ export default function TerminalChat({ additionalWritableRoots, fullStdout, }: Props): React.ReactElement { - // Desktop notification setting const notify = config.notify; const [model, setModel] = useState(config.model); const [provider, setProvider] = useState(config.provider || "openai"); const [lastResponseId, setLastResponseId] = useState(null); const [items, setItems] = useState>([]); const [loading, setLoading] = useState(false); - // Allow switching approval modes at runtime via an overlay. const [approvalPolicy, setApprovalPolicy] = useState( initialApprovalPolicy, ); const [thinkingSeconds, setThinkingSeconds] = useState(0); + const handleCompact = async () => { setLoading(true); try { @@ -185,6 +181,7 @@ export default function TerminalChat({ setLoading(false); } }; + const { requestConfirmation, confirmationPrompt, @@ -215,13 +212,13 @@ export default function TerminalChat({ // DEBUG: log every render w/ key bits of state // ──────────────────────────────────────────────────────────────── log( - `render – agent? ${Boolean(agentRef.current)} loading=${loading} items=${ + `render - agent? ${Boolean(agentRef.current)} loading=${loading} items=${ items.length }`, ); useEffect(() => { - // Skip recreating the agent if awaiting a decision on a pending confirmation + // Skip recreating the agent if awaiting a decision on a pending confirmation. if (confirmationPrompt != null) { log("skip AgentLoop recreation due to pending confirmationPrompt"); return; @@ -234,7 +231,7 @@ export default function TerminalChat({ )} approvalPolicy=${approvalPolicy}`, ); - // Tear down any existing loop before creating a new one + // Tear down any existing loop before creating a new one. agentRef.current?.terminate(); const sessionId = crypto.randomUUID(); @@ -267,11 +264,9 @@ export default function TerminalChat({ , ); - // If the user wants an explanation, generate one and ask again + // If the user wants an explanation, generate one and ask again. if (review === ReviewDecision.EXPLAIN) { log(`Generating explanation for command: ${commandForDisplay}`); - - // Generate an explanation using the same model const explanation = await generateCommandExplanation( command, model, @@ -279,7 +274,7 @@ export default function TerminalChat({ ); log(`Generated explanation: ${explanation}`); - // Ask for confirmation again, but with the explanation + // Ask for confirmation again, but with the explanation. const confirmResult = await requestConfirmation( , ); - // Update the decision based on the second confirmation + // Update the decision based on the second confirmation. review = confirmResult.decision; customDenyMessage = confirmResult.customDenyMessage; - // Return the final decision with the explanation + // Return the final decision with the explanation. return { review, customDenyMessage, applyPatch, explanation }; } @@ -299,7 +294,7 @@ export default function TerminalChat({ }, }); - // force a render so JSX below can "see" the freshly created agent + // Force a render so JSX below can "see" the freshly created agent. forceUpdate(); log(`AgentLoop created: ${inspect(agentRef.current, { depth: 1 })}`); @@ -320,7 +315,7 @@ export default function TerminalChat({ additionalWritableRoots, ]); - // whenever loading starts/stops, reset or start a timer — but pause the + // 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(() => { @@ -345,14 +340,15 @@ export default function TerminalChat({ }; }, [loading, confirmationPrompt]); - // Notify desktop with a preview when an assistant response arrives + // Notify desktop with a preview when an assistant response arrives. const prevLoadingRef = useRef(false); useEffect(() => { - // Only notify when notifications are enabled + // Only notify when notifications are enabled. if (!notify) { prevLoadingRef.current = loading; return; } + if ( prevLoadingRef.current && !loading && @@ -389,7 +385,7 @@ export default function TerminalChat({ prevLoadingRef.current = loading; }, [notify, loading, confirmationPrompt, items, PWD]); - // Let's also track whenever the ref becomes available + // Let's also track whenever the ref becomes available. const agent = agentRef.current; useEffect(() => { log(`agentRef.current is now ${Boolean(agent)}`); @@ -412,7 +408,7 @@ export default function TerminalChat({ const inputItems = [ await createInputItem(initialPrompt || "", initialImagePaths || []), ]; - // Clear them to prevent subsequent runs + // Clear them to prevent subsequent runs. setInitialPrompt(""); setInitialImagePaths([]); agent?.run(inputItems); @@ -447,7 +443,7 @@ export default function TerminalChat({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Just render every item in order, no grouping/collapse + // Just render every item in order, no grouping/collapse. const lastMessageBatch = items.map((item) => ({ item })); const groupCounts: Record = {}; const userMsgCount = items.filter( @@ -626,10 +622,10 @@ export default function TerminalChat({ agent?.cancel(); setLoading(false); - // Select default model for the new provider + // Select default model for the new provider. const defaultModel = model; - // Save provider to config + // Save provider to config. const updatedConfig = { ...config, provider: newProvider, @@ -669,13 +665,12 @@ export default function TerminalChat({ { - // update approval policy without cancelling an in-progress session + // Update approval policy without cancelling an in-progress session. if (newMode === approvalPolicy) { return; } - // update state + setApprovalPolicy(newMode as ApprovalPolicy); - // update existing AgentLoop instance if (agentRef.current) { ( agentRef.current as unknown as { diff --git a/codex-cli/src/components/chat/terminal-header.tsx b/codex-cli/src/components/chat/terminal-header.tsx index bdc49946..1bd08aef 100644 --- a/codex-cli/src/components/chat/terminal-header.tsx +++ b/codex-cli/src/components/chat/terminal-header.tsx @@ -34,9 +34,9 @@ const TerminalHeader: React.FC = ({ {terminalRows < 10 ? ( // Compact header for small terminal windows - ● Codex v{version} – {PWD} – {model} ({provider}) –{" "} + ● Codex v{version} - {PWD} - {model} ({provider}) -{" "} {approvalPolicy} - {flexModeEnabled ? " – flex-mode" : ""} + {flexModeEnabled ? " - flex-mode" : ""} ) : ( <> diff --git a/codex-cli/src/components/singlepass-cli-app.tsx b/codex-cli/src/components/singlepass-cli-app.tsx index c52ae1be..0c5eeb4e 100644 --- a/codex-cli/src/components/singlepass-cli-app.tsx +++ b/codex-cli/src/components/singlepass-cli-app.tsx @@ -399,8 +399,8 @@ export function SinglePassApp({ }); const openai = new OpenAI({ - apiKey: getApiKey(config.provider), - baseURL: getBaseUrl(config.provider), + apiKey: getApiKey(config.provider ?? "openai"), + baseURL: getBaseUrl(config.provider ?? "openai"), timeout: OPENAI_TIMEOUT_MS, }); const chatResp = await openai.beta.chat.completions.parse({ diff --git a/codex-cli/src/hooks/use-confirmation.ts b/codex-cli/src/hooks/use-confirmation.ts index b10a309d..07c9e75c 100644 --- a/codex-cli/src/hooks/use-confirmation.ts +++ b/codex-cli/src/hooks/use-confirmation.ts @@ -1,4 +1,3 @@ -// use-confirmation.ts import type { ReviewDecision } from "../utils/agent/review"; import type React from "react"; diff --git a/codex-cli/src/text-buffer.ts b/codex-cli/src/text-buffer.ts index d7ce1530..fe4e2a47 100644 --- a/codex-cli/src/text-buffer.ts +++ b/codex-cli/src/text-buffer.ts @@ -423,7 +423,7 @@ export default class TextBuffer { /** Delete the word to the *left* of the caret, mirroring common * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent * whitespace *and* the word characters immediately preceding the caret are - * removed. If the caret is already at column‑0 this becomes a no‑op. */ + * removed. If the caret is already at column‑0 this becomes a no-op. */ deleteWordLeft(): void { dbg("deleteWordLeft", { beforeCursor: this.getCursor() }); @@ -710,7 +710,7 @@ export default class TextBuffer { } endSelection(): void { - // no‑op for now, kept for API symmetry + // no-op for now, kept for API symmetry // we rely on anchor + current cursor to compute selection } diff --git a/codex-cli/src/utils/agent/agent-loop.ts b/codex-cli/src/utils/agent/agent-loop.ts index 09bb26f8..9cf5d30f 100644 --- a/codex-cli/src/utils/agent/agent-loop.ts +++ b/codex-cli/src/utils/agent/agent-loop.ts @@ -744,7 +744,7 @@ export class AgentLoop { for await (const event of stream as AsyncIterable) { log(`AgentLoop.run(): response event ${event.type}`); - // process and surface each item (no‑op until we can depend on streaming events) + // process and surface each item (no-op until we can depend on streaming events) if (event.type === "response.output_item.done") { const item = event.item; // 1) if it's a reasoning item, annotate it @@ -936,7 +936,7 @@ export class AgentLoop { ], }); } catch { - /* no‑op – emitting the error message is best‑effort */ + /* no-op – emitting the error message is best‑effort */ } this.onLoading(false); return; diff --git a/codex-cli/src/utils/agent/handle-exec-command.ts b/codex-cli/src/utils/agent/handle-exec-command.ts index ea87feca..aea2c3a7 100644 --- a/codex-cli/src/utils/agent/handle-exec-command.ts +++ b/codex-cli/src/utils/agent/handle-exec-command.ts @@ -144,7 +144,7 @@ export async function handleExecCommand( abortSignal, ); // If the operation was aborted in the meantime, propagate the cancellation - // upward by returning an empty (no‑op) result so that the agent loop will + // upward by returning an empty (no-op) result so that the agent loop will // exit cleanly without emitting spurious output. if (abortSignal?.aborted) { return { diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts index 91e0c367..a4a9c0cb 100644 --- a/codex-cli/src/utils/config.ts +++ b/codex-cli/src/utils/config.ts @@ -41,7 +41,7 @@ export function setApiKey(apiKey: string): void { OPENAI_API_KEY = apiKey; } -export function getBaseUrl(provider: string = "openai"): string | undefined { +export function getBaseUrl(provider: string): string | undefined { const providerInfo = providers[provider.toLowerCase()]; if (providerInfo) { return providerInfo.baseURL; @@ -49,7 +49,7 @@ export function getBaseUrl(provider: string = "openai"): string | undefined { return undefined; } -export function getApiKey(provider: string = "openai"): string | undefined { +export function getApiKey(provider: string): string | undefined { const providerInfo = providers[provider.toLowerCase()]; if (providerInfo) { if (providerInfo.name === "Ollama") { diff --git a/codex-cli/src/utils/model-utils.ts b/codex-cli/src/utils/model-utils.ts index 946756cb..31f1afe6 100644 --- a/codex-cli/src/utils/model-utils.ts +++ b/codex-cli/src/utils/model-utils.ts @@ -1,3 +1,6 @@ +import type { ResponseItem } from "openai/resources/responses/responses.mjs"; + +import { approximateTokensUsed } from "./approximate-tokens-used.js"; import { getBaseUrl, getApiKey } from "./config"; import OpenAI from "openai"; @@ -11,9 +14,8 @@ export const RECOMMENDED_MODELS: Array = ["o4-mini", "o3"]; * enters interactive mode. The request is made exactly once during the * lifetime of the process and the results are cached for subsequent calls. */ - async function fetchModels(provider: string): Promise> { - // If the user has not configured an API key we cannot hit the network. + // If the user has not configured an API key we cannot retrieve the models. if (!getApiKey(provider)) { throw new Error("No API key configured for provider: " + provider); } @@ -26,7 +28,7 @@ async function fetchModels(provider: string): Promise> { for await (const model of list as AsyncIterable<{ id?: string }>) { if (model && typeof model.id === "string") { let modelStr = model.id; - // fix for gemini + // Fix for gemini. if (modelStr.startsWith("models/")) { modelStr = modelStr.replace("models/", ""); } @@ -40,6 +42,7 @@ async function fetchModels(provider: string): Promise> { } } +/** Returns the list of models available for the provided key / credentials. */ export async function getAvailableModels( provider: string, ): Promise> { @@ -47,11 +50,11 @@ export async function getAvailableModels( } /** - * Verify that the provided model identifier is present in the set returned by - * {@link getAvailableModels}. The list of models is fetched from the OpenAI - * `/models` endpoint the first time it is required and then cached in‑process. + * Verifies that the provided model identifier is present in the set returned by + * {@link getAvailableModels}. */ export async function isModelSupportedForResponses( + provider: string, model: string | undefined | null, ): Promise { if ( @@ -64,7 +67,7 @@ export async function isModelSupportedForResponses( try { const models = await Promise.race>([ - getAvailableModels("openai"), + getAvailableModels(provider), new Promise>((resolve) => setTimeout(() => resolve([]), MODEL_LIST_TIMEOUT_MS), ), @@ -82,3 +85,110 @@ export async function isModelSupportedForResponses( return true; } } + +/** Returns the maximum context length (in tokens) for a given model. */ +function maxTokensForModel(model: string): number { + // TODO: These numbers are best‑effort guesses and provide a basis for UI percentages. They + // should be provider & model specific instead of being wild guesses. + + 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; + } + return 128000; // Default to 128k for any other model. +} + +/** Calculates the percentage of tokens remaining in context for a model. */ +export function calculateContextPercentRemaining( + items: Array, + model: string, +): number { + const used = approximateTokensUsed(items); + const max = maxTokensForModel(model); + const remaining = Math.max(0, max - used); + return (remaining / max) * 100; +} + +/** + * 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"; +} + +/** + * 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 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): Array { + const seenIds = new Set(); + const deduped: Array = []; + + 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; +} diff --git a/codex-cli/src/utils/terminal-chat-utils.ts b/codex-cli/src/utils/terminal-chat-utils.ts new file mode 100644 index 00000000..e69de29b diff --git a/codex-cli/tests/agent-project-doc.test.ts b/codex-cli/tests/agent-project-doc.test.ts index 4b8951e7..d421c268 100644 --- a/codex-cli/tests/agent-project-doc.test.ts +++ b/codex-cli/tests/agent-project-doc.test.ts @@ -50,7 +50,7 @@ vi.mock("openai", () => { // The AgentLoop pulls these helpers in order to decide whether a command can // be auto‑approved. None of that matters for this test, so we stub the module -// with minimal no‑op implementations. +// with minimal no-op implementations. vi.mock("../src/approvals.js", () => { return { __esModule: true, diff --git a/codex-cli/tests/approvals.test.ts b/codex-cli/tests/approvals.test.ts index a4c08b04..43490cf8 100644 --- a/codex-cli/tests/approvals.test.ts +++ b/codex-cli/tests/approvals.test.ts @@ -79,7 +79,7 @@ describe("canAutoApprove()", () => { test("true command is considered safe", () => { expect(check(["true"])).toEqual({ type: "auto-approve", - reason: "No‑op (true)", + reason: "No-op (true)", group: "Utility", runInSandbox: false, }); diff --git a/codex-cli/tests/config.test.tsx b/codex-cli/tests/config.test.tsx index 49b3229b..867b957d 100644 --- a/codex-cli/tests/config.test.tsx +++ b/codex-cli/tests/config.test.tsx @@ -26,7 +26,7 @@ vi.mock("fs", async () => { memfs[path] = data; }, mkdirSync: () => { - // no‑op in in‑memory store + // no-op in in‑memory store }, rmSync: (path: string) => { // recursively delete any key under this prefix diff --git a/codex-cli/tests/model-utils-network-error.test.ts b/codex-cli/tests/model-utils-network-error.test.ts index f67b4fc4..537e7fdb 100644 --- a/codex-cli/tests/model-utils-network-error.test.ts +++ b/codex-cli/tests/model-utils-network-error.test.ts @@ -44,7 +44,7 @@ describe("model-utils – offline resilience", () => { "../src/utils/model-utils.js" ); - const supported = await isModelSupportedForResponses("o4-mini"); + const supported = await isModelSupportedForResponses("openai", "o4-mini"); expect(supported).toBe(true); }); @@ -63,8 +63,11 @@ describe("model-utils – offline resilience", () => { "../src/utils/model-utils.js" ); - // Should resolve true despite the network failure - const supported = await isModelSupportedForResponses("some-model"); + // Should resolve true despite the network failure. + const supported = await isModelSupportedForResponses( + "openai", + "some-model", + ); expect(supported).toBe(true); }); }); diff --git a/codex-cli/tests/multiline-history-behavior.test.tsx b/codex-cli/tests/multiline-history-behavior.test.tsx index cada52dd..ee46d3d5 100644 --- a/codex-cli/tests/multiline-history-behavior.test.tsx +++ b/codex-cli/tests/multiline-history-behavior.test.tsx @@ -57,7 +57,7 @@ async function type( await flush(); } -/** Build a set of no‑op callbacks so renders with minimal +/** Build a set of no-op callbacks so renders with minimal * scaffolding. */ function stubProps(): any { diff --git a/codex-cli/tests/text-buffer.test.ts b/codex-cli/tests/text-buffer.test.ts index c3f33d0f..e5c532f7 100644 --- a/codex-cli/tests/text-buffer.test.ts +++ b/codex-cli/tests/text-buffer.test.ts @@ -127,7 +127,7 @@ describe("TextBuffer – basic editing parity with Rust suite", () => { expect(buf.getCursor()).toEqual([0, 2]); // after 'b' }); - it("is a no‑op at the very beginning of the buffer", () => { + it("is a no-op at the very beginning of the buffer", () => { const buf = new TextBuffer("ab"); buf.backspace(); // caret starts at (0,0) diff --git a/codex-cli/tests/typeahead-scroll.test.tsx b/codex-cli/tests/typeahead-scroll.test.tsx index fab7c753..e0e496ad 100644 --- a/codex-cli/tests/typeahead-scroll.test.tsx +++ b/codex-cli/tests/typeahead-scroll.test.tsx @@ -26,7 +26,7 @@ vi.mock("../src/components/select-input/select-input.js", () => { // Ink's toggles raw‑mode which calls .ref() / .unref() on stdin. // The test environment's mock streams don't implement those methods, so we -// polyfill them to no‑ops on the prototype *before* the component tree mounts. +// polyfill them to no-ops on the prototype *before* the component tree mounts. import { EventEmitter } from "node:events"; if (!(EventEmitter.prototype as any).ref) { (EventEmitter.prototype as any).ref = () => {};