From c3e4f920b4e965085164d6ee0249a873ef96da77 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 3 Dec 2025 10:23:45 -0800 Subject: [PATCH 001/159] chore: remove bun env var detect (#7534) ### Summary [Thread](https://openai.slack.com/archives/C08JZTV654K/p1764780129457519) We were a bit aggressive on assuming package installer based on env variables for BUN. Here we are removing those checks. --- codex-cli/bin/codex.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 805be85af8..138796e5d6 100644 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -95,14 +95,6 @@ function detectPackageManager() { return "bun"; } - if ( - process.env.BUN_INSTALL || - process.env.BUN_INSTALL_GLOBAL_DIR || - process.env.BUN_INSTALL_BIN_DIR - ) { - return "bun"; - } - return userAgent ? "npm" : null; } From 343aa35db1ec369185ebc961f237fe7f7a0994ac Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:41:38 -0800 Subject: [PATCH 002/159] chore: update app-server README (#7510) Just keeping the README up to date. - Reorganize structure a bit to read more naturally - Update RPC methods - Update events --- codex-rs/app-server/README.md | 71 ++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e94a2c133..ee28637b2a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -5,11 +5,11 @@ ## Table of Contents - [Protocol](#protocol) - [Message Schema](#message-schema) +- [Core Primitives](#core-primitives) - [Lifecycle Overview](#lifecycle-overview) - [Initialization](#initialization) -- [Core primitives](#core-primitives) -- [Thread & turn endpoints](#thread--turn-endpoints) -- [Events (work-in-progress)](#events-work-in-progress) +- [API Overview](#api-overview) +- [Events](#events) - [Auth endpoints](#auth-endpoints) ## Protocol @@ -25,6 +25,15 @@ codex app-server generate-ts --out DIR codex app-server generate-json-schema --out DIR ``` +## Core Primitives + +The API exposes three top level primitives representing an interaction between a user and Codex: +- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns. +- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. +- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc. + +Use the thread APIs to create, list, or archive conversations. Drive a conversation with turn APIs and stream progress via turn notifications. + ## Lifecycle Overview - Initialize once: Immediately after launching the codex app-server process, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request before this handshake gets rejected. @@ -37,28 +46,16 @@ codex app-server generate-json-schema --out DIR Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error. -Example: +Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. +Example (from OpenAI's official VSCode extension): ```json { "method": "initialize", "id": 0, "params": { "clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" } } } -{ "id": 0, "result": { "userAgent": "codex-app-server/0.1.0 codex-vscode/0.1.0" } } -{ "method": "initialized" } ``` -## Core primitives - -We have 3 top level primitives: -- Thread - a conversation between the Codex agent and a user. Each thread contains multiple turns. -- Turn - one turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. -- Item - represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. - -## Thread & turn endpoints - -The JSON-RPC API exposes dedicated methods for managing Codex conversations. Threads store long-lived conversation metadata, and turns store the per-message exchange (input → Codex output, including streamed items). Use the thread APIs to create, list, or archive sessions, then drive the conversation with turn APIs and notifications. - -### Quick reference +## API Overview - `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. @@ -67,8 +64,14 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `model/list` — list available models (with reasoning effort options). +- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. +- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `config/read` — fetch the effective config on disk after resolving config layering. +- `config/value/write` — write a single config key/value to the user's config.toml on disk. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. -### 1) Start or resume a thread +### Example: Start or resume a thread Start a fresh thread when you need a new Codex conversation. @@ -99,7 +102,7 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -### 2) List threads (pagination & filters) +### Example: List threads (with pagination & filters) `thread/list` lets you render a history UI. Pass any combination of: - `cursor` — opaque string from a prior response; omit for the first page. @@ -124,7 +127,7 @@ Example: When `nextCursor` is `null`, you’ve reached the final page. -### 3) Archive a thread +### Example: Archive a thread Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory. @@ -135,7 +138,7 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di An archived thread will not appear in future calls to `thread/list`. -### 4) Start a turn (send user input) +### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: @@ -169,7 +172,7 @@ You can optionally specify config overrides on the new turn. If specified, these } } } ``` -### 5) Interrupt an active turn +### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -183,7 +186,7 @@ You can cancel a running Turn with `turn/interrupt`. The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done. -### 6) Request a code review +### Example: Request a code review Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed: @@ -242,7 +245,7 @@ containing an `exitedReviewMode` item with the final review text: The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client. -### 7) One-off command execution +### Example: One-off command execution Run a standalone command (argv vector) in the server’s sandbox without creating a thread or turn: @@ -261,7 +264,7 @@ Notes: - `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags). - When omitted, `timeoutMs` falls back to the server default. -## Events (work-in-progress) +## Events Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `turn/*`, and `item/*` notifications. @@ -271,11 +274,12 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`. +- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. -#### Thread items +#### Items `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: - `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). @@ -285,6 +289,9 @@ Today both notifications carry an empty `items` array even when item events were - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `webSearch` — `{id, query}` for a web search request issued by the agent. +- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. +- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description. +- `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings). - `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically. All items emit two shared lifecycle events: @@ -302,7 +309,7 @@ There are additional item-specific events: - `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. #### fileChange -`fileChange` items contain a `changes` list with `{path, kind, diff}` entries (`kind` is `add`, `delete`, or `update` with an optional `movePath`). The `status` tracks whether apply succeeded (`completed`), failed, or was `declined`. +- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call. ### Errors `error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification. @@ -351,7 +358,7 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. -### Quick reference +### API Overview - `account/read` — fetch current account info; optionally refresh tokens. - `account/login/start` — begin login (`apiKey` or `chatgpt`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). @@ -436,9 +443,3 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. - -### Dev notes - -- `codex app-server generate-ts --out ` emits v2 types under `v2/`. -- `codex app-server generate-json-schema --out ` outputs `codex_app_server_protocol.schemas.json`. -- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. From 844de19561b87f76c60d77db3ccfc9415c43b230 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:47:12 -0800 Subject: [PATCH 003/159] chore: delete unused TodoList item from app-server (#7537) This item is sent as a turn notification instead: `turn/plan/updated`, similar to Turn diffs (which is `turn/diff/updated`). We treat these concepts as ephemeral compared to Items which are usually persisted. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e3990432f6..e1122e8146 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1144,9 +1144,6 @@ pub enum ThreadItem { WebSearch { id: String, query: String }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - TodoList { id: String, items: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] ImageView { id: String, path: String }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -1249,15 +1246,6 @@ pub struct McpToolCallError { pub message: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TodoItem { - pub id: String, - pub text: String, - pub completed: bool, -} - // === Server Notifications === // Thread/Turn lifecycle notifications and item progress events #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] From 3ef76ff29d5eed258fb6b8550e0e2b973d0dca21 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:47:35 -0800 Subject: [PATCH 004/159] chore: conversation_id -> thread_id in app-server feedback/upload (#7538) Use `thread_id: Option` instead of `conversation_id: Option` to be consistent with the rest of app-server v2 APIs. --- .../app-server-protocol/src/protocol/v2.rs | 3 +-- .../app-server/src/codex_message_processor.rs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e1122e8146..c672080720 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::protocol::common::AuthMode; -use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningEffort; @@ -664,7 +663,7 @@ pub struct ListMcpServersResponse { pub struct FeedbackUploadParams { pub classification: String, pub reason: Option, - pub conversation_id: Option, + pub thread_id: Option, pub include_logs: bool, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 245486e482..6b85049d28 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3021,10 +3021,26 @@ impl CodexMessageProcessor { let FeedbackUploadParams { classification, reason, - conversation_id, + thread_id, include_logs, } = params; + let conversation_id = match thread_id.as_deref() { + Some(thread_id) => match ConversationId::from_string(thread_id) { + Ok(conversation_id) => Some(conversation_id), + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => None, + }; + let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); From 2ad980abf47128a20668e5ccadf6e2565b2f46fc Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 11:25:44 -0800 Subject: [PATCH 005/159] add slash resume (#7302) `codex resume` isn't that discoverable. Adding it to the slash commands can help --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 67 ++++++ codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 56 +++++ ...chat_composer__tests__slash_popup_res.snap | 11 + codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/tests.rs | 9 + codex-rs/tui/src/resume_picker.rs | 191 ++++++++++++++++-- codex-rs/tui/src/slash_command.rs | 3 + ...e_picker__tests__resume_picker_screen.snap | 14 ++ docs/slash_commands.md | 1 + 12 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9fcc969b3..a1684bf0f5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1625,6 +1625,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.1", "url", + "uuid", "vt100", ] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index be4f5aead7..828255a582 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -110,3 +110,4 @@ pretty_assertions = { workspace = true } rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fa3e610e22..504452bc80 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -489,6 +489,73 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_from_rollout( + self.config.clone(), + path.clone(), + self.auth_manager.clone(), + ) + .await + { + Ok(resumed) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cf494f57d6..944eeda810 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -22,6 +22,9 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Open the resume picker inside the running TUI session. + OpenResumePicker, + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f78e544ea3..e9343fd8a4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2376,6 +2376,62 @@ mod tests { } } + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/res'") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000..df8ea36e63 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2385 +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e0bb0d3a69..0f1d6918d0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1482,6 +1482,9 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c44401b3e7..43056464de 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1185,6 +1185,15 @@ fn slash_exit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); } +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + #[test] fn slash_undo_sends_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1cc9624ec3..f2c6a3269d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -113,7 +113,7 @@ pub async fn run_resume_picker( show_all, filter_cwd, ); - state.load_initial_page().await?; + state.start_initial_load(); state.request_frame(); let mut tui_events = alt.tui.event_stream().fuse(); @@ -359,25 +359,28 @@ impl PickerState { Ok(None) } - async fn load_initial_page(&mut self) -> Result<()> { - let provider_filter = vec![self.default_provider.clone()]; - let page = RolloutRecorder::list_conversations( - &self.codex_home, - PAGE_SIZE, - None, - INTERACTIVE_SESSION_SOURCES, - Some(provider_filter.as_slice()), - self.default_provider.as_str(), - ) - .await?; + fn start_initial_load(&mut self) { self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear(); self.seen_paths.clear(); self.search_state = SearchState::Idle; self.selected = 0; - self.ingest_page(page); - Ok(()) + + let request_token = self.allocate_request_token(); + self.pagination.loading = LoadingState::Pending(PendingLoad { + request_token, + search_token: None, + }); + self.request_frame(); + + (self.page_loader)(PageLoadRequest { + codex_home: self.codex_home.clone(), + cursor: None, + request_token, + search_token: None, + default_provider: self.default_provider.clone(), + }); } fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { @@ -1260,6 +1263,166 @@ mod tests { assert_snapshot!("resume_picker_table", snapshot); } + #[test] + fn resume_picker_screen_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use uuid::Uuid; + + // Create real rollout files so the snapshot uses the actual listing pipeline. + let tempdir = tempfile::tempdir().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); + + let now = Utc::now(); + + // Helper to write a rollout file with minimal meta + one user message. + let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { + let dir = sessions_root + .join(ts.format("%Y").to_string()) + .join(ts.format("%m").to_string()) + .join(ts.format("%d").to_string()); + std::fs::create_dir_all(&dir).expect("mkdir date dirs"); + let filename = format!( + "rollout-{}-{}.jsonl", + ts.format("%Y-%m-%dT%H-%M-%S"), + Uuid::new_v4() + ); + let path = dir.join(filename); + let meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "SessionMeta": { + "meta": { + "id": Uuid::new_v4(), + "timestamp": ts.to_rfc3339(), + "cwd": cwd, + "originator": "user", + "cli_version": "0.0.0", + "instructions": null, + "source": "Cli", + "model_provider": "openai", + } + } + } + }); + let user = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "UserMessage": { + "message": preview, + "images": null + } + } + } + }); + let branch_meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "SessionMeta": { + "meta": { + "git_branch": branch + } + } + } + } + }); + std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) + .expect("write rollout"); + }; + + write_rollout( + now - Duration::seconds(42), + "/tmp/project", + "feature/resume", + "Fix resume picker timestamps", + ); + write_rollout( + now - Duration::minutes(35), + "/tmp/other", + "main", + "Investigate lazy pagination cap", + ); + + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + true, + None, + ); + + let page = block_on_future(RolloutRecorder::list_conversations( + &state.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + )) + .expect("list conversations"); + + let rows = rows_from_items(page.items); + state.all_rows = rows.clone(); + state.filtered_rows = rows; + state.view_rows = Some(4); + state.selected = 0; + state.scroll_top = 0; + state.update_view_rows(4); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + + let width: u16 = 80; + let height: u16 = 9; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let [header, search, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + frame.render_widget_ref(Line::from("Type to search".dim()), search); + + render_column_headers(&mut frame, columns, &metrics); + render_list(&mut frame, list, &state, &metrics); + + let hint_line: Line = vec![ + key_hint::plain(KeyCode::Enter).into(), + " to resume ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_screen", snapshot); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 969d279b07..47b330cba6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Approvals, Review, New, + Resume, Init, Compact, Undo, @@ -40,6 +41,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Resume => "resume a saved chat", SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -64,6 +66,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Resume | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Undo diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap new file mode 100644 index 0000000000..79a169a06d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +assertion_line: 1438 +expression: snapshot +--- +Resume a previous session +Type to search + Updated Branch CWD Conversation +No sessions yet + + + + +enter to resume esc to start new ctrl + c to quit diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4c1a244764..6961461d42 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -16,6 +16,7 @@ Control Codex’s behavior during an interactive session with slash commands. | `/approvals` | choose what Codex can do without approval | | `/review` | review my current changes and find issues | | `/new` | start a new chat during a conversation | +| `/resume` | resume an old chat | | `/init` | create an AGENTS.md file with instructions for Codex | | `/compact` | summarize conversation to prevent hitting the context limit | | `/undo` | ask Codex to undo a turn | From 8d0f023fa9d691f4d850953da046d2918d427c78 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:06:47 +0000 Subject: [PATCH 006/159] chore: update unified exec sandboxing detection (#7541) No integration test for now because it would make them flaky. Tracking it in my todos to add some once we have a clock based system for integration tests --- codex-rs/core/src/exec.rs | 13 +++++++ codex-rs/core/src/message_history.rs | 2 +- codex-rs/core/src/unified_exec/session.rs | 15 +++----- .../core/src/unified_exec/session_manager.rs | 37 +++++++++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index f46444675f..ba1ac43004 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -485,6 +485,19 @@ pub struct ExecToolCallOutput { pub timed_out: bool, } +impl Default for ExecToolCallOutput { + fn default() -> Self { + Self { + exit_code: 0, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::ZERO, + timed_out: false, + } + } +} + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] async fn exec( params: ExecParams, diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index e46dd93067..ecc6851336 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -590,7 +590,7 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].text, long_entry); - let pruned_len = std::fs::metadata(&history_path).expect("metadata").len() as u64; + let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); let max_bytes = config .history .max_bytes diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 710334c806..a6e4167ade 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -149,7 +149,7 @@ impl UnifiedExecSession { guard.snapshot() } - fn sandbox_type(&self) -> SandboxType { + pub(crate) fn sandbox_type(&self) -> SandboxType { self.sandbox_type } @@ -172,10 +172,8 @@ impl UnifiedExecSession { let exec_output = ExecToolCallOutput { exit_code, stdout: StreamOutput::new(aggregated_text.clone()), - stderr: StreamOutput::new(String::new()), aggregated_output: StreamOutput::new(aggregated_text.clone()), - duration: Duration::ZERO, - timed_out: false, + ..Default::default() }; if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) { @@ -184,7 +182,7 @@ impl UnifiedExecSession { TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), ); let message = if snippet.is_empty() { - format!("exit code {exit_code}") + format!("Session creation failed with exit code {exit_code}") } else { snippet }; @@ -205,10 +203,7 @@ impl UnifiedExecSession { } = spawned; let managed = Self::new(session, output_rx, sandbox_type); - let exit_ready = match exit_rx.try_recv() { - Ok(_) | Err(TryRecvError::Closed) => true, - Err(TryRecvError::Empty) => false, - }; + let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed)); if exit_ready { managed.signal_exit(); @@ -216,7 +211,7 @@ impl UnifiedExecSession { return Ok(managed); } - if tokio::time::timeout(Duration::from_millis(50), &mut exit_rx) + if tokio::time::timeout(Duration::from_millis(150), &mut exit_rx) .await .is_ok() { diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 72c02cdb99..d37ad4d3fc 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -153,6 +153,7 @@ impl UnifiedExecSessionManager { let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let sandbox_type = session.sandbox_type(); let chunk_id = generate_chunk_id(); let process_id = if has_exited { None @@ -201,6 +202,9 @@ impl UnifiedExecSessionManager { Some(request.process_id), ) .await; + + // Exit code should always be Some + sandboxing::check_sandboxing(sandbox_type, &text, exit_code.unwrap_or_default())?; } Ok(response) @@ -703,6 +707,39 @@ impl UnifiedExecSessionManager { } } +mod sandboxing { + use super::*; + use crate::exec::SandboxType; + use crate::exec::is_likely_sandbox_denied; + use crate::unified_exec::UNIFIED_EXEC_OUTPUT_MAX_TOKENS; + + pub(crate) fn check_sandboxing( + sandbox_type: SandboxType, + text: &str, + exit_code: i32, + ) -> Result<(), UnifiedExecError> { + let exec_output = ExecToolCallOutput { + exit_code, + stderr: StreamOutput::new(text.to_string()), + aggregated_output: StreamOutput::new(text.to_string()), + ..Default::default() + }; + if is_likely_sandbox_denied(sandbox_type, &exec_output) { + let snippet = formatted_truncate_text( + text, + TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), + ); + let message = if snippet.is_empty() { + format!("Session exited with code {exit_code}") + } else { + snippet + }; + return Err(UnifiedExecError::sandbox_denied(message, exec_output)); + } + Ok(()) + } +} + enum SessionStatus { Alive { exit_code: Option, From 9e6c2c1e64cd327a9e9975fadc7bd127583f084c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:06:55 +0000 Subject: [PATCH 007/159] feat: add pycache to excluded directories (#7545) --- codex-rs/utils/git/src/ghost_commits.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/utils/git/src/ghost_commits.rs b/codex-rs/utils/git/src/ghost_commits.rs index 6a3eec4894..8544525f0f 100644 --- a/codex-rs/utils/git/src/ghost_commits.rs +++ b/codex-rs/utils/git/src/ghost_commits.rs @@ -42,6 +42,7 @@ const DEFAULT_IGNORED_DIR_NAMES: &[&str] = &[ ".mypy_cache", ".cache", ".tox", + "__pycache__", ]; /// Options to control ghost commit creation. From 7f068cfbcca0b0028ac23625e4097a93f9beeacf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:15:12 +0000 Subject: [PATCH 008/159] fix: main (#7546) --- codex-rs/tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 504452bc80..32fc095662 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -523,6 +523,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = ChatWidget::new_from_existing( init, From 71504325d3d34a399f5ad958fd13043d6d74062c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 12:30:43 -0800 Subject: [PATCH 009/159] Migrate model preset (#7542) - Introduce `openai_models` in `/core` - Move `PRESETS` under it - Move `ModelPreset`, `ModelUpgrade`, `ReasoningEffortPreset`, `ReasoningEffortPreset`, and `ReasoningEffortPreset` to `protocol` - Introduce `Op::ListModels` and `EventMsg::AvailableModels` Next steps: - migrate `app-server` and `tui` to use the introduced Operation --- codex-rs/Cargo.lock | 2 - .../app-server-protocol/src/protocol/v1.rs | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 +- codex-rs/app-server/src/models.rs | 8 +- .../suite/codex_message_processor_flow.rs | 2 +- codex-rs/app-server/tests/suite/config.rs | 2 +- .../app-server/tests/suite/v2/model_list.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 2 +- codex-rs/codex-api/src/common.rs | 2 +- codex-rs/common/Cargo.toml | 2 - codex-rs/common/src/lib.rs | 2 - codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/codex.rs | 26 ++- codex-rs/core/src/config/edit.rs | 4 +- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/config/profile.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/model_family.rs | 2 +- codex-rs/core/src/openai_models/mod.rs | 1 + .../src/openai_models}/model_presets.rs | 184 +++++++---------- codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/sandboxing/assessment.rs | 2 +- codex-rs/core/tests/suite/client.rs | 2 +- codex-rs/core/tests/suite/list_models.rs | 187 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 2 +- .../src/event_processor_with_human_output.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 2 +- codex-rs/otel/src/otel_event_manager.rs | 2 +- codex-rs/protocol/src/config_types.rs | 29 --- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/openai_models.rs | 80 ++++++++ codex-rs/protocol/src/protocol.rs | 7 +- codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 13 +- codex-rs/tui/src/chatwidget/tests.rs | 18 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/model_migration.rs | 6 +- codex-rs/tui/src/status/tests.rs | 2 +- 42 files changed, 430 insertions(+), 197 deletions(-) create mode 100644 codex-rs/core/src/openai_models/mod.rs rename codex-rs/{common/src => core/src/openai_models}/model_presets.rs (68%) create mode 100644 codex-rs/core/tests/suite/list_models.rs create mode 100644 codex-rs/protocol/src/openai_models.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a1684bf0f5..4429858c91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1117,12 +1117,10 @@ name = "codex-common" version = "0.0.0" dependencies = [ "clap", - "codex-app-server-protocol", "codex-core", "codex-lmstudio", "codex-ollama", "codex-protocol", - "once_cell", "serde", "toml", ] diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 54f80c9fd4..1576eb0d93 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -3,11 +3,11 @@ use std::path::PathBuf; use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c672080720..e3c9fc0558 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,11 +4,11 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index d03795c2d4..78f6fd5851 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,9 +1,9 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::ReasoningEffortPreset; -use codex_common::model_presets::builtin_model_presets; +use codex_core::openai_models::model_presets::builtin_model_presets; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; pub fn supported_models(auth_mode: Option) -> Vec { builtin_model_presets(auth_mode) @@ -27,7 +27,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { } fn reasoning_efforts_from_preset( - efforts: &'static [ReasoningEffortPreset], + efforts: Vec, ) -> Vec { efforts .iter() diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index a64aca8051..4b206436c8 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -23,10 +23,10 @@ use codex_app_server_protocol::SendUserTurnResponse; use codex_app_server_protocol::ServerRequest; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 75dba57229..88e74a6fb4 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -10,10 +10,10 @@ use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 3c4844fed9..8ca85c9c3b 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; -use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 03ee279e51..e4cd722947 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -30,8 +30,8 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::path::Path; diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index addab02dc7..19e82de332 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -1,8 +1,8 @@ use crate::error::ApiError; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; use futures::Stream; diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 377d054483..25264eff09 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -9,12 +9,10 @@ workspace = true [dependencies] clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } -codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } codex-lmstudio = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } -once_cell = { workspace = true } serde = { workspace = true, optional = true } toml = { workspace = true, optional = true } diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 5092b3be24..d5513b8325 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -32,8 +32,6 @@ mod config_summary; pub use config_summary::create_config_summary_entries; // Shared fuzzy matcher (used by TUI selection popups and other UI filtering) pub mod fuzzy_match; -// Shared model presets used by TUI and MCP server -pub mod model_presets; // Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server // Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy. pub mod approval_presets; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 82839522c9..f4248f30ab 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -20,9 +20,9 @@ use codex_api::error::ApiError; use codex_app_server_protocol::AuthMode; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use eventsource_stream::Event; use eventsource_stream::EventStreamError; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 682861d648..29e7a1ce88 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -22,6 +22,7 @@ use crate::user_notification::UserNotifier; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; +use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; @@ -126,12 +127,12 @@ use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_execpolicy::Policy as ExecPolicy; use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; @@ -638,6 +639,14 @@ impl Session { Ok(sess) } + pub(crate) fn get_auth_mode(&self) -> AuthMode { + self.services + .auth_manager + .auth() + .map(|a| a.mode) + .unwrap_or(AuthMode::ApiKey) + } + pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } @@ -1478,6 +1487,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Review { review_request } => { handlers::review(&sess, &config, sub.id.clone(), review_request).await; } + Op::ListModels => { + handlers::list_models(&sess, sub.id.clone(), Some(sess.get_auth_mode())).await; + } _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. } } @@ -1494,12 +1506,15 @@ mod handlers { use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; + use crate::openai_models::model_presets::builtin_model_presets; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; use crate::tasks::UserShellCommandTask; + use codex_app_server_protocol::AuthMode; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::openai_models::AvailableModelsEvent; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -1814,6 +1829,15 @@ mod handlers { } } } + + pub async fn list_models(sess: &Arc, sub_id: String, auth_mode: Option) { + let models = builtin_model_presets(auth_mode); + let event = Event { + id: sub_id, + msg: EventMsg::ListModelsResponse(AvailableModelsEvent { models }), + }; + sess.send_event_raw(event).await; + } } /// Spawn a review thread using the given prompt. diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index b8862fa5c5..68e2d206f0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -2,8 +2,8 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; use anyhow::Context; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; @@ -574,7 +574,7 @@ impl ConfigEditsBuilder { mod tests { use super::*; use crate::config::types::McpServerTransportConfig; - use codex_protocol::config_types::ReasoningEffort; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::tempdir; use tokio::runtime::Builder; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6276d3b6e3..dccf0556f1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -38,11 +38,11 @@ use crate::util::resolve_path; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use dunce::canonicalize; diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 3d9e60b8e5..5629465c40 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -2,10 +2,10 @@ use serde::Deserialize; use std::path::PathBuf; use crate::protocol::AskForApproval; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d9ab6ee51f..d32366476a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -32,6 +32,7 @@ pub mod git_info; pub mod landlock; pub mod mcp; mod mcp_connection_manager; +pub mod openai_models; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_NOTIFICATION; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 5dea1c0168..0417f13b12 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -1,5 +1,5 @@ -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use crate::config::types::ReasoningSummaryFormat; use crate::tools::handlers::apply_patch::ApplyPatchToolType; diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs new file mode 100644 index 0000000000..7df68c4ab7 --- /dev/null +++ b/codex-rs/core/src/openai_models/mod.rs @@ -0,0 +1 @@ +pub mod model_presets; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs similarity index 68% rename from codex-rs/common/src/model_presets.rs rename to codex-rs/core/src/openai_models/model_presets.rs index a031f23b1d..f649d88741 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -1,76 +1,38 @@ -use std::collections::HashMap; - use codex_app_server_protocol::AuthMode; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt-5.1-codex-max_migration_prompt"; -/// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Copy)] -pub struct ReasoningEffortPreset { - /// Effort level that the model supports. - pub effort: ReasoningEffort, - /// Short human description shown next to the effort in UIs. - pub description: &'static str, -} - -#[derive(Debug, Clone)] -pub struct ModelUpgrade { - pub id: &'static str, - pub reasoning_effort_mapping: Option>, - pub migration_config_key: &'static str, -} - -/// Metadata describing a Codex-supported model. -#[derive(Debug, Clone)] -pub struct ModelPreset { - /// Stable identifier for the preset. - pub id: &'static str, - /// Model slug (e.g., "gpt-5"). - pub model: &'static str, - /// Display name shown in UIs. - pub display_name: &'static str, - /// Short human description shown in UIs. - pub description: &'static str, - /// Reasoning effort applied when none is explicitly chosen. - pub default_reasoning_effort: ReasoningEffort, - /// Supported reasoning effort options. - pub supported_reasoning_efforts: &'static [ReasoningEffortPreset], - /// Whether this is the default model for new users. - pub is_default: bool, - /// recommended upgrade model - pub upgrade: Option, - /// Whether this preset should appear in the picker UI. - pub show_in_picker: bool, -} - static PRESETS: Lazy> = Lazy::new(|| { vec![ ModelPreset { - id: "gpt-5.1-codex-max", - model: "gpt-5.1-codex-max", - display_name: "gpt-5.1-codex-max", - description: "Latest Codex-optimized flagship for deep and fast reasoning.", + id: "gpt-5.1-codex-max".to_string(), + model: "gpt-5.1-codex-max".to_string(), + display_name: "gpt-5.1-codex-max".to_string(), + description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning", + description: "Fast responses with lighter reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks", + description: "Balances speed and reasoning depth for everyday tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems", + description: "Maximizes reasoning depth for complex problems".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems", + description: "Extra high reasoning depth for complex problems".to_string(), }, ], is_default: true, @@ -78,167 +40,169 @@ static PRESETS: Lazy> = Lazy::new(|| { show_in_picker: true, }, ModelPreset { - id: "gpt-5.1-codex", - model: "gpt-5.1-codex", - display_name: "gpt-5.1-codex", - description: "Optimized for codex.", + id: "gpt-5.1-codex".to_string(), + model: "gpt-5.1-codex".to_string(), + display_name: "gpt-5.1-codex".to_string(), + description: "Optimized for codex.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning", + description: "Fastest responses with limited reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, ModelPreset { - id: "gpt-5.1-codex-mini", - model: "gpt-5.1-codex-mini", - display_name: "gpt-5.1-codex-mini", - description: "Optimized for codex. Cheaper, faster, but less capable.", + id: "gpt-5.1-codex-mini".to_string(), + model: "gpt-5.1-codex-mini".to_string(), + display_name: "gpt-5.1-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, ModelPreset { - id: "gpt-5.1", - model: "gpt-5.1", - display_name: "gpt-5.1", - description: "Broad world knowledge with strong general reasoning.", + id: "gpt-5.1".to_string(), + model: "gpt-5.1".to_string(), + display_name: "gpt-5.1".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, // Deprecated models. ModelPreset { - id: "gpt-5-codex", - model: "gpt-5-codex", - display_name: "gpt-5-codex", - description: "Optimized for codex.", + id: "gpt-5-codex".to_string(), + model: "gpt-5-codex".to_string(), + display_name: "gpt-5-codex".to_string(), + description: "Optimized for codex.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning", + description: "Fastest responses with limited reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, ModelPreset { - id: "gpt-5-codex-mini", - model: "gpt-5-codex-mini", - display_name: "gpt-5-codex-mini", - description: "Optimized for codex. Cheaper, faster, but less capable.", + id: "gpt-5-codex-mini".to_string(), + model: "gpt-5-codex-mini".to_string(), + display_name: "gpt-5-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-mini", + id: "gpt-5.1-codex-mini".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, ModelPreset { - id: "gpt-5", - model: "gpt-5", - display_name: "gpt-5", - description: "Broad world knowledge with strong general reasoning.", + id: "gpt-5".to_string(), + model: "gpt-5".to_string(), + display_name: "gpt-5".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Minimal, - description: "Fastest responses with little reasoning", + description: "Fastest responses with little reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f9336..4b7d7d3068 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -51,6 +51,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::Warning(_) | EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) + | EventMsg::ListModelsResponse(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 719e3be1f0..225825c93e 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -14,9 +14,9 @@ use crate::protocol::SandboxPolicy; use askama::Template; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SessionSource; use futures::StreamExt; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e074d29755..e0e06757b5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,11 +22,11 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs new file mode 100644 index 0000000000..ecfe9138e4 --- /dev/null +++ b/codex-rs/core/tests/suite/list_models.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_models_returns_api_key_models() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_auth(CodexAuth::from_api_key("sk-test")); + let test = builder.build(&server).await?; + + test.codex.submit(Op::ListModels).await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ListModelsResponse(models) => Some(models.clone()), + _ => None, + }) + .await; + + let expected_models = expected_models_for_api_key(); + assert_eq!(expected_models, event.models); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_models_returns_chatgpt_models() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let test = builder.build(&server).await?; + + test.codex.submit(Op::ListModels).await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ListModelsResponse(models) => Some(models.clone()), + _ => None, + }) + .await; + + let expected_models = expected_models_for_chatgpt(); + assert_eq!(expected_models, event.models); + + Ok(()) +} + +fn expected_models_for_api_key() -> Vec { + vec![gpt_5_1_codex(), gpt_5_1_codex_mini(), gpt_5_1()] +} + +fn expected_models_for_chatgpt() -> Vec { + vec![ + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + ] +} + +fn gpt_5_1_codex_max() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex-max".to_string(), + model: "gpt-5.1-codex-max".to_string(), + display_name: "gpt-5.1-codex-max".to_string(), + description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Fast responses with lighter reasoning", + ), + effort( + ReasoningEffort::Medium, + "Balances speed and reasoning depth for everyday tasks", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex problems", + ), + effort( + ReasoningEffort::XHigh, + "Extra high reasoning depth for complex problems", + ), + ], + is_default: true, + upgrade: None, + show_in_picker: true, + } +} + +fn gpt_5_1_codex() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex".to_string(), + model: "gpt-5.1-codex".to_string(), + display_name: "gpt-5.1-codex".to_string(), + description: "Optimized for codex.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Fastest responses with limited reasoning", + ), + effort( + ReasoningEffort::Medium, + "Dynamically adjusts reasoning based on the task", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1_codex_mini() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex-mini".to_string(), + model: "gpt-5.1-codex-mini".to_string(), + display_name: "gpt-5.1-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Medium, + "Dynamically adjusts reasoning based on the task", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1() -> ModelPreset { + ModelPreset { + id: "gpt-5.1".to_string(), + model: "gpt-5.1".to_string(), + display_name: "gpt-5.1".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Balances speed with some reasoning; useful for straightforward queries and short explanations", + ), + effort( + ReasoningEffort::Medium, + "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1_codex_max_upgrade() -> codex_protocol::openai_models::ModelUpgrade { + codex_protocol::openai_models::ModelUpgrade { + id: "gpt-5.1-codex-max".to_string(), + reasoning_effort_mapping: None, + migration_config_key: "hide_gpt-5.1-codex-max_migration_prompt".to_string(), + } +} + +fn effort(reasoning_effort: ReasoningEffort, description: &str) -> ReasoningEffortPreset { + ReasoningEffortPreset { + effort: reasoning_effort, + description: description.to_string(), + } +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index b877663614..35d4eb52a4 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -34,6 +34,7 @@ mod grep_files; mod items; mod json_result; mod list_dir; +mod list_models; mod live_cli; mod model_overrides; mod model_tools; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a186c13ef3..f67196312f 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -2,7 +2,7 @@ use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index f4455fd022..0c908e35be 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -7,10 +7,10 @@ use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses::mount_sse_once; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f35..17a167585b 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::UndoCompleted(_) + | EventMsg::ListModelsResponse(_) | EventMsg::UndoStarted(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 55808f17ca..2b3fd07707 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -307,6 +307,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::ContextCompacted(_) + | EventMsg::ListModelsResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 9e9d079306..83ac25fdfd 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -239,7 +239,7 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; - use codex_protocol::config_types::ReasoningEffort; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index b6bc07e79f..c300f3fb82 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -2,9 +2,9 @@ use chrono::SecondsFormat; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 2ee6d39746..a98ec4e2b2 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -2,37 +2,8 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; -use strum_macros::EnumIter; use ts_rs::TS; -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive( - Debug, - Serialize, - Deserialize, - Default, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - EnumIter, - Hash, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - None, - Minimal, - Low, - #[default] - Medium, - High, - XHigh, -} - /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 08ea753347..0d6a0594fc 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -8,6 +8,7 @@ pub mod items; pub mod message_history; pub mod models; pub mod num_format; +pub mod openai_models; pub mod parse_command; pub mod plan_tool; pub mod protocol; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs new file mode 100644 index 0000000000..f9a05f9ff6 --- /dev/null +++ b/codex-rs/protocol/src/openai_models.rs @@ -0,0 +1,80 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use strum_macros::EnumIter; +use ts_rs::TS; + +/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning +#[derive( + Debug, + Serialize, + Deserialize, + Default, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + EnumIter, + Hash, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReasoningEffort { + None, + Minimal, + Low, + #[default] + Medium, + High, + XHigh, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct AvailableModelsEvent { + pub models: Vec, +} + +/// A reasoning effort option that can be surfaced for a model. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ReasoningEffortPreset { + /// Effort level that the model supports. + pub effort: ReasoningEffort, + /// Short human description shown next to the effort in UIs. + pub description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ModelUpgrade { + pub id: String, + pub reasoning_effort_mapping: Option>, + pub migration_config_key: String, +} + +/// Metadata describing a Codex-supported model. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ModelPreset { + /// Stable identifier for the preset. + pub id: String, + /// Model slug (e.g., "gpt-5"). + pub model: String, + /// Display name shown in UIs. + pub display_name: String, + /// Short human description shown in UIs. + pub description: String, + /// Reasoning effort applied when none is explicitly chosen. + pub default_reasoning_effort: ReasoningEffort, + /// Supported reasoning effort options. + pub supported_reasoning_efforts: Vec, + /// Whether this is the default model for new users. + pub is_default: bool, + /// recommended upgrade model + pub upgrade: Option, + /// Whether this preset should appear in the picker UI. + pub show_in_picker: bool, +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 347cc119ff..6b787afe17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -12,7 +12,6 @@ use std::time::Duration; use crate::ConversationId; use crate::approvals::ElicitationRequestEvent; -use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; @@ -20,6 +19,8 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; +use crate::openai_models::AvailableModelsEvent; +use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use crate::user_input::UserInput; @@ -208,6 +209,9 @@ pub enum Op { /// The raw command string after '!' command: String, }, + + /// Request the list of available models. + ListModels, } /// Determines the conditions under which the user is consulted to approve @@ -578,6 +582,7 @@ pub enum EventMsg { AgentMessageContentDelta(AgentMessageContentDeltaEvent), ReasoningContentDelta(ReasoningContentDeltaEvent), ReasoningRawContentDelta(ReasoningRawContentDeltaEvent), + ListModelsResponse(AvailableModelsEvent), } /// Codex errors that we expose to clients. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32fc095662..409f047217 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -21,10 +21,6 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::AuthMode; -use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::ModelUpgrade; -use codex_common::model_presets::all_model_presets; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; @@ -32,14 +28,18 @@ use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::all_model_presets; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_core::skills::load_skills; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 944eeda810..3a199593bb 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,18 +1,18 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_common::model_presets::ModelPreset; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; #[allow(clippy::large_enum_variant)] #[derive(Debug)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0f1d6918d0..f18725d8bd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -122,15 +122,15 @@ use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::builtin_model_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::openai_models::model_presets::builtin_model_presets; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; @@ -1833,6 +1833,7 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) + | EventMsg::ListModelsResponse(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } @@ -2074,7 +2075,7 @@ impl ChatWidget { let description = if preset.description.is_empty() { Some("Uses fewer credits for upcoming turns.".to_string()) } else { - Some(preset.description.to_string()) + Some(preset.description) }; let items = vec![ @@ -2210,9 +2211,9 @@ impl ChatWidget { if choices.len() == 1 { if let Some(effort) = choices.first().and_then(|c| c.stored) { - self.apply_model_and_effort(preset.model.to_string(), Some(effort)); + self.apply_model_and_effort(preset.model, Some(effort)); } else { - self.apply_model_and_effort(preset.model.to_string(), None); + self.apply_model_and_effort(preset.model, None); } return; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 43056464de..a4d21608c8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5,8 +5,6 @@ use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_common::approval_presets::builtin_approval_presets; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::ReasoningEffortPreset; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -48,6 +46,8 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; @@ -1805,17 +1805,17 @@ fn reasoning_popup_shows_extra_high_with_space() { fn single_reasoning_option_skips_selection() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - static SINGLE_EFFORT: [ReasoningEffortPreset; 1] = [ReasoningEffortPreset { + let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }]; let preset = ModelPreset { - id: "model-with-single-reasoning", - model: "model-with-single-reasoning", - display_name: "model-with-single-reasoning", - description: "", + id: "model-with-single-reasoning".to_string(), + model: "model-with-single-reasoning".to_string(), + display_name: "model-with-single-reasoning".to_string(), + description: "".to_string(), default_reasoning_effort: ReasoningEffortConfig::High, - supported_reasoning_efforts: &SINGLE_EFFORT, + supported_reasoning_efforts: single_effort, is_default: false, upgrade: None, show_in_picker: true, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 475eb1db17..c4fd31f548 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -31,7 +31,7 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 283007e028..1f93fd9a4f 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -7,8 +7,8 @@ use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; -use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -329,7 +329,7 @@ mod tests { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; - use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; + use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use insta::assert_snapshot; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index ae379aae67..0709e366d0 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -13,8 +13,8 @@ use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; use ratatui::prelude::*; use std::path::PathBuf; From 3395ebd96ef50d459287e0d2303e89360b18b57a Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Thu, 4 Dec 2025 05:43:17 +0800 Subject: [PATCH 010/159] fix(tui): limit user shell output by screen lines (#7448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What - Limit the TUI "user shell" output panel by the number of visible screen lines rather than by the number of logical lines. - Apply middle truncation after wrapping, so a few extremely long lines cannot expand into hundreds of visible lines. - Add a regression test to guard this behavior. Why When the `ExecCommandSource::UserShell` tool returns a small number of very long logical lines, the TUI wraps those lines into many visual lines. The existing truncation logic applied `USER_SHELL_TOOL_CALL_MAX_LINES` to the number of logical lines *before* wrapping. As a result, a command like: - `Ran bash -lc "grep -R --line-number 'maskAssetId' ."` or a synthetic command that prints a single ~50,000‑character line, can produce hundreds of screen lines and effectively flood the viewport. The intended middle truncation for user shell output does not take effect in this scenario. How - In `codex-rs/tui/src/exec_cell/render.rs`, change the `ExecCell` rendering path for `ExecCommandSource::UserShell` so that: - Each logical line from `CommandOutput::aggregated_output` is first wrapped via `word_wrap_line` into multiple screen lines using the appropriate `RtOptions` and width from the `EXEC_DISPLAY_LAYOUT` configuration. - `truncate_lines_middle` is then applied to the wrapped screen lines, with `USER_SHELL_TOOL_CALL_MAX_LINES` as the limit. This means the limit is enforced on visible screen lines, not logical lines. - The existing layout struct (`ExecDisplayLayout`) continues to provide `output_max_lines`, so user shell output is subject to both `USER_SHELL_TOOL_CALL_MAX_LINES` and the layout-specific `output_max_lines` constraint. - Keep using `USER_SHELL_TOOL_CALL_MAX_LINES` as the cap, but interpret it as a per‑tool‑call limit on screen lines. - Add a regression test `user_shell_output_is_limited_by_screen_lines` in `codex-rs/tui/src/exec_cell/render.rs` that: - Constructs two extremely long logical lines containing a short marker (`"Z"`), so each wrapped screen line still contains the marker. - Wraps them at a narrow width to generate many screen lines. - Asserts that the unbounded wrapped output would exceed `USER_SHELL_TOOL_CALL_MAX_LINES` screen lines. - Renders an `ExecCell` for `ExecCommandSource::UserShell` at the same width and counts rendered lines containing the marker. - Asserts `output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES`, guarding against regressions where truncation happens before wrapping. This change keeps user shell output readable while ensuring it cannot flood the TUI, even when the tool emits a few extremely long lines. Tests - `cargo test -p codex-tui` Issue - Fixes #7447 --- codex-rs/tui/src/exec_cell/render.rs | 116 ++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3e434138d4..3375f1dca8 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -451,26 +451,26 @@ impl ExecCell { )); } } else { - let trimmed_output = Self::truncate_lines_middle( - &raw_output.lines, - display_limit, - raw_output.omitted, - ); - + // Wrap first so that truncation is applied to on-screen lines + // rather than logical lines. This ensures that a small number + // of very long lines cannot flood the viewport. let mut wrapped_output: Vec> = Vec::new(); let output_wrap_width = layout.output_block.wrap_width(width); let output_opts = RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - for line in trimmed_output { + for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(&line, output_opts.clone()), + &word_wrap_line(line, output_opts.clone()), &mut wrapped_output, ); } - if !wrapped_output.is_empty() { + let trimmed_output = + Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + + if !trimmed_output.is_empty() { lines.extend(prefix_lines( - wrapped_output, + trimmed_output, Span::from(layout.output_block.initial_prefix).dim(), Span::from(layout.output_block.subsequent_prefix), )); @@ -597,3 +597,99 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( PrefixedBlock::new(" └ ", " "), 5, ); + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::ExecCommandSource; + + #[test] + fn user_shell_output_is_limited_by_screen_lines() { + // Construct a user shell exec cell whose aggregated output consists of a + // small number of very long logical lines. These will wrap into many + // on-screen lines at narrow widths. + // + // Use a short marker so it survives wrapping intact inside each + // rendered screen line; the previous test used a marker longer than + // the wrap width, so it was split across lines and the assertion + // never actually saw it. + let marker = "Z"; + let long_chunk = marker.repeat(800); + let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + + // Baseline: how many screen lines would we get if we simply wrapped + // all logical lines without any truncation? + let output = CommandOutput { + exit_code: 0, + aggregated_output, + formatted_output: String::new(), + }; + let width = 20; + let layout = EXEC_DISPLAY_LAYOUT; + let raw_output = output_lines( + Some(&output), + OutputLinesParams { + // Large enough to include all logical lines without + // triggering the ellipsis in `output_lines`. + line_limit: 100, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + let mut full_wrapped_output: Vec> = Vec::new(); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut full_wrapped_output, + ); + } + let full_screen_lines = full_wrapped_output + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Sanity check: this scenario should produce more screen lines than + // the user shell per-call limit when no truncation is applied. If + // this ever fails, the test no longer exercises the regression. + assert!( + full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, + "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", + ); + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo long".into()], + parsed: Vec::new(), + output: Some(output), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + + // Use a narrow width so each logical line wraps into many on-screen lines. + let lines = cell.command_display_lines(width); + + // Count how many rendered lines contain our marker text. This approximates + // the number of visible output "screen lines" for this command. + let output_screen_lines = lines + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Regression guard: previously this scenario could render hundreds of + // wrapped lines because truncation happened before wrapping. Now the + // truncation is applied after wrapping, so the number of visible + // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + assert!( + output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + ); + } +} From de08c735a6b7d54ac9efb742e1d50b68b852f0d0 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Thu, 4 Dec 2025 06:43:31 +0800 Subject: [PATCH 011/159] feat(tui): map Ctrl-P/N to arrow navigation in textarea (#7530) - Treat Ctrl-P/N (and their C0 fallbacks) the same as Up/Down so cursor movement matches popup/history behavior and control bytes never land in the buffer Fixes #7529 Signed-off-by: Aofei Sheng --- codex-rs/tui/src/bottom_pane/textarea.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 03de336811..2fd415c7f6 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -216,7 +216,7 @@ impl TextArea { match event { // Some terminals (or configurations) send Control key chords as // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get + // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get // inserted as literal control bytes. KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { self.move_cursor_left(); @@ -224,6 +224,12 @@ impl TextArea { KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { self.move_cursor_right(); } + KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { + self.move_cursor_up(); + } + KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { + self.move_cursor_down(); + } KeyEvent { code: KeyCode::Char(c), // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, @@ -359,6 +365,20 @@ impl TextArea { } => { self.move_cursor_right(); } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_down(); + } // Some terminals send Alt+Arrow for word-wise movement: // Option/Left -> Alt+Left (previous word start) // Option/Right -> Alt+Right (next word end) From 231ff19ca227e3f25e75742e938da0531093c30a Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 15:00:07 -0800 Subject: [PATCH 012/159] [app-server] fix: add thread_id to turn/plan/updated (#7553) Realized we're missing this while migrating VSCE. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/src/bespoke_event_handling.rs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e3c9fc0558..f1d8392135 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1294,6 +1294,7 @@ pub struct TurnDiffUpdatedNotification { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct TurnPlanUpdatedNotification { + pub thread_id: String, pub turn_id: String, pub explanation: Option, pub plan: Vec, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b4dd16b9a6..df4cdb8980 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -661,6 +661,7 @@ pub(crate) async fn apply_bespoke_event_handling( } EventMsg::PlanUpdate(plan_update_event) => { handle_turn_plan_update( + conversation_id, &event_turn_id, plan_update_event, api_version, @@ -693,6 +694,7 @@ async fn handle_turn_diff( } async fn handle_turn_plan_update( + conversation_id: ConversationId, event_turn_id: &str, plan_update_event: UpdatePlanArgs, api_version: ApiVersion, @@ -700,6 +702,7 @@ async fn handle_turn_plan_update( ) { if let ApiVersion::V2 = api_version { let notification = TurnPlanUpdatedNotification { + thread_id: conversation_id.to_string(), turn_id: event_turn_id.to_string(), explanation: plan_update_event.explanation, plan: plan_update_event @@ -1422,7 +1425,16 @@ mod tests { ], }; - handle_turn_plan_update("turn-123", update, ApiVersion::V2, &outgoing).await; + let conversation_id = ConversationId::new(); + + handle_turn_plan_update( + conversation_id, + "turn-123", + update, + ApiVersion::V2, + &outgoing, + ) + .await; let msg = rx .recv() @@ -1430,6 +1442,7 @@ mod tests { .ok_or_else(|| anyhow!("should send one notification"))?; match msg { OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => { + assert_eq!(n.thread_id, conversation_id.to_string()); assert_eq!(n.turn_id, "turn-123"); assert_eq!(n.explanation.as_deref(), Some("need plan")); assert_eq!(n.plan.len(), 2); From 9a50a0440090a3c0dfefbd215a00209a31bee52e Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 3 Dec 2025 15:12:46 -0800 Subject: [PATCH 013/159] feat: Support listing and selecting skills via $ or /skills (#7506) List/Select skills with $-mention or /skills --- codex-rs/core/src/project_doc.rs | 6 +- codex-rs/core/src/skills/render.rs | 21 ++ codex-rs/tui/src/app.rs | 17 +- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 307 ++++++++++++++---- codex-rs/tui/src/bottom_pane/command_popup.rs | 66 ++-- codex-rs/tui/src/bottom_pane/mod.rs | 28 +- codex-rs/tui/src/bottom_pane/skill_popup.rs | 142 ++++++++ codex-rs/tui/src/chatwidget.rs | 9 + codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/slash_command.rs | 3 + 11 files changed, 505 insertions(+), 97 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/skill_popup.rs diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index ee3148e7e7..43a0034801 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -516,8 +516,9 @@ mod tests { ) .unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})" + "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } @@ -535,8 +536,9 @@ mod tests { dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})" + "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core/src/skills/render.rs index d547e21c28..b664565459 100644 --- a/codex-rs/core/src/skills/render.rs +++ b/codex-rs/core/src/skills/render.rs @@ -17,5 +17,26 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { )); } + lines.push( + r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 4) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."### + .to_string(), + ); + Some(lines.join("\n")) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 409f047217..df9e4b5d4b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,7 +25,6 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -37,6 +36,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -231,6 +231,8 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + pub(crate) skills: Option>, } impl App { @@ -285,6 +287,12 @@ impl App { } } + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { @@ -298,6 +306,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new(init, conversation_manager.clone()) @@ -322,6 +331,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new_from_existing( @@ -357,6 +367,7 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -476,6 +487,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); @@ -523,6 +535,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new_from_existing( @@ -1147,6 +1160,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, } } @@ -1184,6 +1198,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, }, rx, op_rx, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 677f29abdd..a7c8611614 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e9343fd8a4..4529b66566 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -29,6 +29,7 @@ use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::skill_popup::SkillPopup; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -53,6 +54,7 @@ use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; @@ -115,6 +117,8 @@ pub(crate) struct ChatComposer { footer_hint_override: Option>, context_window_percent: Option, context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, } /// Popup state – at most one can be visible at any time. @@ -122,6 +126,7 @@ enum ActivePopup { None, Command(CommandPopup), File(FileSearchPopup), + Skill(SkillPopup), } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -160,12 +165,18 @@ impl ChatComposer { footer_hint_override: None, context_window_percent: None, context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -178,6 +189,9 @@ impl ChatComposer { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } ActivePopup::None => Constraint::Max(footer_total_height), }; let [composer_rect, popup_rect] = @@ -234,14 +248,7 @@ impl ChatComposer { } // Explicit paste events should not trigger Enter suppression. self.paste_burst.clear_after_explicit_paste(); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } @@ -286,8 +293,7 @@ impl ChatComposer { self.attached_images.clear(); self.textarea.set_text(&text); self.textarea.set_cursor(0); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { @@ -377,8 +383,7 @@ impl ChatComposer { pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } /// Handle a key event coming from the main UI. @@ -386,16 +391,12 @@ impl ChatComposer { let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); result } @@ -465,6 +466,11 @@ impl ChatComposer { let mut cursor_target: Option = None; match sel { CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); @@ -714,23 +720,101 @@ impl ChatComposer { } } + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + let ActivePopup::Skill(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_skill_token() { + self.dismissed_skill_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let selected = popup.selected_skill().map(|skill| skill.name.clone()); + if let Some(name) = selected { + self.insert_selected_skill(&name); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + fn is_image_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } - /// Extract the `@token` that the cursor is currently positioned on, if any. + fn skills_enabled(&self) -> bool { + self.skills.as_ref().is_some_and(|s| !s.is_empty()) + } + + /// Extract a token prefixed with `prefix` under the cursor, if any. /// - /// The returned string **does not** include the leading `@`. + /// The returned string **does not** include the prefix. /// /// Behavior: /// - The cursor may be anywhere *inside* the token (including on the - /// leading `@`). It does **not** need to be at the end of the line. + /// leading prefix). It does **not** need to be at the end of the line. /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `@`, that token is - /// returned without the leading `@`. This includes the case where the - /// token is just "@" (empty query), which is used to trigger a UI hint - fn current_at_token(textarea: &TextArea) -> Option { + /// - If the token under the cursor starts with `prefix`, that token is + /// returned without the leading prefix. When `allow_empty` is true, a + /// lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -799,26 +883,40 @@ impl ChatComposer { None }; - let left_at = token_left - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); - let right_at = token_right - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); + let prefix_str = prefix.to_string(); + let left_match = token_left.filter(|t| t.starts_with(prefix)); + let right_match = token_right.filter(|t| t.starts_with(prefix)); + + let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); + let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); if at_whitespace { - if right_at.is_some() { - return right_at; + if right_prefixed.is_some() { + return right_prefixed; } - if token_left.is_some_and(|t| t == "@") { - return None; + if token_left.is_some_and(|t| t == prefix_str) { + return allow_empty.then(String::new); } - return left_at; + return left_prefixed; } - if after_cursor.starts_with('@') { - return right_at.or(left_at); + if after_cursor.starts_with(prefix) { + return right_prefixed.or(left_prefixed); } - left_at.or(right_at) + left_prefixed.or(right_prefixed) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', false) + } + + fn current_skill_token(&self) -> Option { + if !self.skills_enabled() { + return None; + } + Self::current_prefixed_token(&self.textarea, '$', true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -872,6 +970,41 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn insert_selected_skill(&mut self, skill_name: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + let inserted = format!("${skill_name}"); + + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1075,14 +1208,7 @@ impl ChatComposer { // Mirror insert_str() behavior so popups stay in sync when a // pending fast char flushes as normal typed input. self.textarea.insert_str(ch.to_string().as_str()); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } FlushResult::None => false, @@ -1423,10 +1549,49 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + let skill_token = self.current_skill_token(); + + let allow_command_popup = file_token.is_none() && skill_token.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + self.dismissed_skill_popup_token = None; + return; + } + + if let Some(token) = skill_token { + self.sync_skill_popup(token); + return; + } + self.dismissed_skill_popup_token = None; + + if let Some(token) = file_token { + self.sync_file_search_popup(token); + return; + } + + self.dismissed_file_popup_token = None; + if matches!( + self.active_popup, + ActivePopup::File(_) | ActivePopup::Skill(_) + ) { + self.active_popup = ActivePopup::None; + } + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. - fn sync_command_popup(&mut self) { + fn sync_command_popup(&mut self, allow: bool) { + if !allow { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } // Determine whether the caret is inside the initial '/name' token on the first line. let text = self.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); @@ -1464,7 +1629,9 @@ impl ChatComposer { } _ => { if is_editing_slash_command_name { - let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); + let skills_enabled = self.skills_enabled(); + let mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1481,17 +1648,7 @@ impl ChatComposer { /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. - fn sync_file_search_popup(&mut self) { - // Determine if there is an @token underneath the cursor. - let query = match Self::current_at_token(&self.textarea) { - Some(token) => token, - None => { - self.active_popup = ActivePopup::None; - self.dismissed_file_popup_token = None; - return; - } - }; - + fn sync_file_search_popup(&mut self, query: String) { // If user dismissed popup for this exact query, don't reopen until text changes. if self.dismissed_file_popup_token.as_ref() == Some(&query) { return; @@ -1525,6 +1682,32 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } + fn sync_skill_popup(&mut self, query: String) { + if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + return; + } + + let skills = match self.skills.as_ref() { + Some(skills) if !skills.is_empty() => skills.clone(), + _ => { + self.active_popup = ActivePopup::None; + return; + } + }; + + match &mut self.active_popup { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_skills(skills); + } + _ => { + let mut popup = SkillPopup::new(skills); + popup.set_query(&query); + self.active_popup = ActivePopup::Skill(popup); + } + } + } + fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } @@ -1574,6 +1757,7 @@ impl Renderable for ChatComposer { ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Skill(c) => c.calculate_required_height(width), } } @@ -1586,6 +1770,9 @@ impl Renderable for ChatComposer { ActivePopup::File(popup) => { popup.render_ref(popup_rect, buf); } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } ActivePopup::None => { let footer_props = self.footer_props(); let custom_height = self.custom_footer_height(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d7501cebbc..39bbfbd182 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -31,8 +31,11 @@ pub(crate) struct CommandPopup { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec) -> Self { - let builtins = built_in_slash_commands(); + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -232,7 +235,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -252,7 +255,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -267,7 +270,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -297,7 +300,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts); + let popup = CommandPopup::new(prompts, false); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -313,13 +316,16 @@ mod tests { #[test] fn prompt_name_collision_with_builtin_is_ignored() { // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new(vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), @@ -333,13 +339,16 @@ mod tests { #[test] fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( @@ -350,13 +359,16 @@ mod tests { #[test] fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b4255fd979..a0425c92d7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,6 +8,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -27,6 +28,7 @@ mod file_search_popup; mod footer; mod list_selection_view; mod prompt_args; +mod skill_popup; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; pub(crate) use feedback_view::feedback_selection_params; @@ -87,6 +89,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, } impl BottomPane { @@ -99,15 +102,19 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + skills, } = params; + let mut composer = ChatComposer::new( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ); + composer.set_skill_mentions(skills); + Self { - composer: ChatComposer::new( - has_input_focus, - app_event_tx.clone(), - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - ), + composer, view_stack: Vec::new(), app_event_tx, frame_requester, @@ -578,6 +585,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -599,6 +607,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Create an approval modal (active view). @@ -631,6 +640,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Start a running task so the status indicator is active above the composer. @@ -697,6 +707,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Begin a task: show initial status. @@ -723,6 +734,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Activate spinner (status view replaces composer) with no live ring. @@ -753,6 +765,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); @@ -780,6 +793,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs new file mode 100644 index 0000000000..74c1b137ca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +pub(crate) struct SkillPopup { + query: String, + skills: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(skills: Vec) -> Self { + Self { + query: String::new(), + skills, + state: ScrollState::new(), + } + } + + pub(crate) fn set_skills(&mut self, skills: Vec) { + self.skills = skills; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let skill_idx = matches.get(idx)?; + self.skills.get(*skill_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let skill = &self.skills[idx]; + let slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + GenericDisplayRow { + name, + match_indices: indices, + is_current: false, + display_shortcut: None, + description: Some(description), + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + if filter.is_empty() { + for (idx, _skill) in self.skills.iter().enumerate() { + out.push((idx, None, 0)); + } + return out; + } + + for (idx, skill) in self.skills.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no skills", + ); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f18725d8bd..2ae53bc0c2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -55,6 +55,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; @@ -256,6 +257,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) skills: Option>, pub(crate) is_first_run: bool, } @@ -1231,6 +1233,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, is_first_run, } = common; let mut rng = rand::rng(); @@ -1249,6 +1252,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1307,6 +1311,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, .. } = common; let mut rng = rand::rng(); @@ -1327,6 +1332,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1545,6 +1551,9 @@ impl ChatWidget { SlashCommand::Mention => { self.insert_str("@"); } + SlashCommand::Skills => { + self.insert_str("$"); + } SlashCommand::Status => { self.add_status_output(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a4d21608c8..699435a71a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + skills: None, is_first_run: true, }; let mut w = ChatWidget::new(init, conversation_manager); @@ -380,6 +381,7 @@ fn make_chatwidget_manual() -> ( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let widget = ChatWidget { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 47b330cba6..e0c676812c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Skills, Review, New, Resume, @@ -46,6 +47,7 @@ impl SlashCommand { SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", + SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", @@ -76,6 +78,7 @@ impl SlashCommand { | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention + | SlashCommand::Skills | SlashCommand::Status | SlashCommand::Mcp | SlashCommand::Feedback From 1cfc967eb8cc2aeaf140e67b009ed22e0ab660ca Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Dec 2025 16:12:31 -0800 Subject: [PATCH 014/159] fix: Features should be immutable over the lifetime of a session/thread (#7540) I noticed that `features: Features` was defined on `struct SessionConfiguration`, which is commonly owned by `SessionState`, which is in turn owned by `Session`. Though I do not believe that `Features` should be allowed to be modified over the course of a session (if the feature state is not invariant, it makes it harder to reason about), which argues that it should live on `Session` rather than `SessionState` or `SessionConfiguration`. This PR moves `Features` to `Session` and updates all call sites. It appears the only place we were mutating `Features` was: - in tests - the sub-agent config for a review task: https://github.com/openai/codex/blob/3ef76ff29d5eed258fb6b8550e0e2b973d0dca21/codex-rs/core/src/tasks/review.rs#L86-L89 Note this change also means it is no longer an `async` call to check the state of a feature, eliminating the possibility of a [TOCTTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) error between checking the state of a feature and acting on it: https://github.com/openai/codex/blob/3ef76ff29d5eed258fb6b8550e0e2b973d0dca21/codex-rs/core/src/codex.rs#L1069-L1076 --- codex-rs/core/src/codex.rs | 60 ++++++++++++------------------ codex-rs/core/src/compact.rs | 4 +- codex-rs/core/src/tasks/compact.rs | 2 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 29e7a1ce88..13be377e9c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,6 +12,7 @@ use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::features::Feature; +use crate::features::Features; use crate::function_tool::FunctionCallError; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -190,7 +191,6 @@ impl Codex { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: config.features.clone(), exec_policy, session_source, }; @@ -264,6 +264,9 @@ pub(crate) struct Session { conversation_id: ConversationId, tx_event: Sender, state: Mutex, + /// The set of enabled features should be invariant for the lifetime of the + /// session. + features: Features, pub(crate) active_turn: Mutex>, pub(crate) services: SessionServices, next_internal_sub_id: AtomicU64, @@ -344,8 +347,6 @@ pub(crate) struct SessionConfiguration { /// operate deterministically. cwd: PathBuf, - /// Set of feature flags for this session - features: Features, /// Execpolicy policy, applied only when enabled by feature flag. exec_policy: Arc, @@ -401,6 +402,7 @@ impl Session { sub_id: String, ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); + let features = &config.features; let model_family = find_family_for_model(&session_configuration.model) .unwrap_or_else(|| config.model_family.clone()); let mut per_turn_config = (*config).clone(); @@ -408,6 +410,7 @@ impl Session { per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.features = features.clone(); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } @@ -430,7 +433,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features: &config.features, + features, }); TurnContext { @@ -516,7 +519,7 @@ impl Session { let mut post_session_configured_events = Vec::::new(); - for (alias, feature) in session_configuration.features.legacy_feature_usages() { + for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); let details = if alias == canonical { @@ -575,6 +578,7 @@ impl Session { conversation_id, tx_event: tx_event.clone(), state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -1046,7 +1050,7 @@ impl Session { } pub(crate) async fn record_model_warning(&self, message: impl Into, ctx: &TurnContext) { - if !self.enabled(Feature::ModelWarnings).await { + if !self.enabled(Feature::ModelWarnings) { return; } @@ -1075,13 +1079,8 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - pub async fn enabled(&self, feature: Feature) -> bool { - self.state - .lock() - .await - .session_configuration - .features - .enabled(feature) + pub fn enabled(&self, feature: Feature) -> bool { + self.features.enabled(feature) } async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { @@ -1264,7 +1263,7 @@ impl Session { turn_context: Arc, cancellation_token: CancellationToken, ) { - if !self.enabled(Feature::GhostCommit).await { + if !self.enabled(Feature::GhostCommit) { return; } let token = match turn_context.tool_call_gate.subscribe().await { @@ -1852,7 +1851,7 @@ async fn spawn_review_thread( let review_model_family = find_family_for_model(&model) .unwrap_or_else(|| parent_turn_context.client.get_model_family()); // For reviews, disable web_search and view_image regardless of global settings. - let mut review_features = config.features.clone(); + let mut review_features = sess.features.clone(); review_features .disable(crate::features::Feature::WebSearchRequest) .disable(crate::features::Feature::ViewImageTool); @@ -1873,6 +1872,7 @@ async fn spawn_review_thread( per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; + per_turn_config.features = review_features.clone(); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } @@ -2020,7 +2020,7 @@ pub(crate) async fn run_task( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached { - if should_use_remote_compact_task(&sess).await { + if should_use_remote_compact_task(&sess) { run_inline_remote_auto_compact_task(sess.clone(), turn_context.clone()) .await; } else { @@ -2103,14 +2103,7 @@ async fn run_turn( .supports_parallel_tool_calls; // TODO(jif) revert once testing phase is done. - let parallel_tool_calls = model_supports_parallel - && sess - .state - .lock() - .await - .session_configuration - .features - .enabled(Feature::ParallelToolCalls); + let parallel_tool_calls = model_supports_parallel && sess.enabled(Feature::ParallelToolCalls); let mut base_instructions = turn_context.base_instructions.clone(); if parallel_tool_calls { static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); @@ -2486,7 +2479,6 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) - }) } -use crate::features::Features; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2599,7 +2591,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2798,7 +2789,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2831,6 +2821,7 @@ mod tests { conversation_id, tx_event, state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -2876,7 +2867,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2909,6 +2899,7 @@ mod tests { conversation_id, tx_event, state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -2919,15 +2910,10 @@ mod tests { #[tokio::test] async fn record_model_warning_appends_user_message() { - let (session, turn_context) = make_session_and_context(); - - session - .state - .lock() - .await - .session_configuration - .features - .enable(Feature::ModelWarnings); + let (mut session, turn_context) = make_session_and_context(); + let mut features = Features::with_defaults(); + features.enable(Feature::ModelWarnings); + session.features = features; session .record_model_warning("too many unified exec sessions", &turn_context) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index fb5c187b7f..7ce325a75a 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -32,13 +32,13 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -pub(crate) async fn should_use_remote_compact_task(session: &Session) -> bool { +pub(crate) fn should_use_remote_compact_task(session: &Session) -> bool { session .services .auth_manager .auth() .is_some_and(|auth| auth.mode == AuthMode::ChatGPT) - && session.enabled(Feature::RemoteCompaction).await + && session.enabled(Feature::RemoteCompaction) } pub(crate) async fn run_inline_auto_compact_task( diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 893c0c476a..293116c167 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -25,7 +25,7 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); - if crate::compact::should_use_remote_compact_task(&session).await { + if crate::compact::should_use_remote_compact_task(&session) { crate::compact_remote::run_remote_compact_task(session, ctx).await } else { crate::compact::run_compact_task(session, ctx, input).await From 70b97790bec00bce43eae326200742e76d719339 Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Thu, 4 Dec 2025 08:45:08 +0800 Subject: [PATCH 015/159] fix: wrap long exec lines in transcript overlay (#7481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What ----- - Fix the Ctrl+T transcript overlay so that very long exec output lines are soft‑wrapped to the viewport width instead of being rendered as a single truncated row. - Add a regression test to `TranscriptOverlay` to ensure long exec outputs are rendered on multiple lines in the overlay. Why ---- - Previously, the transcript overlay rendered extremely long single exec lines as one on‑screen row and simply cut them off at the right edge, with no horizontal scrolling. - This made it impossible to inspect the full content of long tool/exec outputs in the transcript view, even though the main TUI view already wrapped those lines. - Fixes #7454. How ---- - Update `ExecCell::transcript_lines` to wrap exec output lines using the existing `RtOptions`/`word_wrap_line` helpers so that transcript rendering is width‑aware. - Reuse the existing line utilities to expand the wrapped `Line` values into the transcript overlay, preserving styling while respecting the current viewport width. - Add `transcript_overlay_wraps_long_exec_output_lines` test in `pager_overlay.rs` that constructs a long single‑line exec output, renders the transcript overlay into a small buffer, and asserts that the long marker string spans multiple rendered lines. --- codex-rs/tui/src/exec_cell/render.rs | 7 ++++- codex-rs/tui/src/pager_overlay.rs | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3375f1dca8..a38cf5a84c 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -219,7 +219,12 @@ impl HistoryCell for ExecCell { if let Some(output) = call.output.as_ref() { if !call.is_unified_exec_interaction() { - lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + let wrap_width = width.max(1) as usize; + let wrap_opts = RtOptions::new(wrap_width); + for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { + let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + push_owned_lines(&wrapped, &mut lines); + } } let duration = call .duration diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70e..b5f7b963cc 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -748,6 +748,49 @@ mod tests { assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } + #[test] + fn transcript_overlay_wraps_long_exec_output_lines() { + let marker = "Z"; + let long_line = marker.repeat(200); + + let mut exec_cell = crate::exec_cell::new_active_exec_command( + "exec-long".into(), + vec!["bash".into(), "-lc".into(), "echo long".into()], + vec![ParsedCommand::Unknown { + cmd: "echo long".into(), + }], + ExecCommandSource::Agent, + None, + false, + ); + exec_cell.complete_call( + "exec-long", + CommandOutput { + exit_code: 0, + aggregated_output: format!("{long_line}\n"), + formatted_output: long_line, + }, + Duration::from_millis(10), + ); + let exec_cell: Arc = Arc::new(exec_cell); + + let mut overlay = TranscriptOverlay::new(vec![exec_cell]); + let area = Rect::new(0, 0, 20, 10); + let mut buf = Buffer::empty(area); + + overlay.render(area, &mut buf); + let rendered = buffer_to_text(&buf, area); + + let wrapped_lines = rendered + .lines() + .filter(|line| line.contains(marker)) + .count(); + assert!( + wrapped_lines >= 2, + "expected long exec output to wrap into multiple lines in transcript overlay, got:\n{rendered}" + ); + } + #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( From 00cc00ead8d45722342a43274af3f8cfc41cc67e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 17:17:56 -0800 Subject: [PATCH 016/159] Introduce `ModelsManager` and migrate `app-server` to use it. (#7552) --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/app-server/src/models.rs | 11 ++++-- codex-rs/core/src/auth.rs | 4 ++ codex-rs/core/src/codex.rs | 24 ------------ codex-rs/core/src/conversation_manager.rs | 10 ++++- codex-rs/core/src/openai_models/mod.rs | 1 + .../core/src/openai_models/models_manager.rs | 26 +++++++++++++ codex-rs/core/src/rollout/policy.rs | 1 - codex-rs/core/tests/suite/list_models.rs | 37 ++++--------------- .../src/event_processor_with_human_output.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/openai_models.rs | 5 --- codex-rs/protocol/src/protocol.rs | 2 - codex-rs/tui/src/chatwidget.rs | 1 - 14 files changed, 56 insertions(+), 71 deletions(-) create mode 100644 codex-rs/core/src/openai_models/models_manager.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6b85049d28..65721a698e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1862,8 +1862,7 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - let models = supported_models(auth_mode); + let models = supported_models(self.conversation_manager.clone()).await; let total = models.len(); if total == 0 { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 78f6fd5851..3ac71e85b9 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,12 +1,15 @@ -use codex_app_server_protocol::AuthMode; +use std::sync::Arc; + use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; -use codex_core::openai_models::model_presets::builtin_model_presets; +use codex_core::ConversationManager; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub fn supported_models(auth_mode: Option) -> Vec { - builtin_model_presets(auth_mode) +pub async fn supported_models(conversation_manager: Arc) -> Vec { + conversation_manager + .list_models() + .await .into_iter() .map(model_from_preset) .collect() diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d874435e8e..a5c9add53f 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1201,4 +1201,8 @@ impl AuthManager { self.reload(); Ok(removed) } + + pub fn get_auth_mode(&self) -> Option { + self.auth().map(|a| a.mode) + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 13be377e9c..885a4cdf74 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -23,7 +23,6 @@ use crate::user_notification::UserNotifier; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; -use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; @@ -643,14 +642,6 @@ impl Session { Ok(sess) } - pub(crate) fn get_auth_mode(&self) -> AuthMode { - self.services - .auth_manager - .auth() - .map(|a| a.mode) - .unwrap_or(AuthMode::ApiKey) - } - pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } @@ -1486,9 +1477,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Review { review_request } => { handlers::review(&sess, &config, sub.id.clone(), review_request).await; } - Op::ListModels => { - handlers::list_models(&sess, sub.id.clone(), Some(sess.get_auth_mode())).await; - } _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. } } @@ -1505,15 +1493,12 @@ mod handlers { use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; - use crate::openai_models::model_presets::builtin_model_presets; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; use crate::tasks::UserShellCommandTask; - use codex_app_server_protocol::AuthMode; use codex_protocol::custom_prompts::CustomPrompt; - use codex_protocol::openai_models::AvailableModelsEvent; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -1828,15 +1813,6 @@ mod handlers { } } } - - pub async fn list_models(sess: &Arc, sub_id: String, auth_mode: Option) { - let models = builtin_model_presets(auth_mode); - let event = Event { - id: sub_id, - msg: EventMsg::ListModelsResponse(AvailableModelsEvent { models }), - }; - sess.send_event_raw(event).await; - } } /// Spawn a review thread using the given prompt. diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 0f4577bf1e..3ac09ec78c 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -7,6 +7,7 @@ use crate::codex_conversation::CodexConversation; use crate::config::Config; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::openai_models::models_manager::ModelsManager; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; @@ -14,6 +15,7 @@ use crate::rollout::RolloutRecorder; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -35,6 +37,7 @@ pub struct NewConversation { pub struct ConversationManager { conversations: Arc>>>, auth_manager: Arc, + models_manager: Arc, session_source: SessionSource, } @@ -42,8 +45,9 @@ impl ConversationManager { pub fn new(auth_manager: Arc, session_source: SessionSource) -> Self { Self { conversations: Arc::new(RwLock::new(HashMap::new())), - auth_manager, + auth_manager: auth_manager.clone(), session_source, + models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), } } @@ -193,6 +197,10 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } + + pub async fn list_models(&self) -> Vec { + self.models_manager.available_models.read().await.clone() + } } /// Return a prefix of `items` obtained by cutting strictly before the nth user message diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index 7df68c4ab7..13ee2e0605 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1 +1,2 @@ pub mod model_presets; +pub mod models_manager; diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs new file mode 100644 index 0000000000..1d57f1e693 --- /dev/null +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -0,0 +1,26 @@ +use codex_app_server_protocol::AuthMode; +use codex_protocol::openai_models::ModelPreset; +use tokio::sync::RwLock; + +use crate::openai_models::model_presets::builtin_model_presets; + +pub struct ModelsManager { + pub available_models: RwLock>, + pub etag: String, + pub auth_mode: Option, +} + +impl ModelsManager { + pub fn new(auth_mode: Option) -> Self { + Self { + available_models: RwLock::new(builtin_model_presets(auth_mode)), + etag: String::new(), + auth_mode, + } + } + + pub async fn refresh_available_models(&self) { + let models = builtin_model_presets(self.auth_mode); + *self.available_models.write().await = models; + } +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 4b7d7d3068..58072f9336 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -51,7 +51,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::Warning(_) | EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) - | EventMsg::ListModelsResponse(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index ecfe9138e4..9303820163 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -1,51 +1,30 @@ use anyhow::Result; use codex_core::CodexAuth; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_core::ConversationManager; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_api_key_models() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_auth(CodexAuth::from_api_key("sk-test")); - let test = builder.build(&server).await?; - - test.codex.submit(Op::ListModels).await?; - - let event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::ListModelsResponse(models) => Some(models.clone()), - _ => None, - }) - .await; + let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); + let models = manager.list_models().await; let expected_models = expected_models_for_api_key(); - assert_eq!(expected_models, event.models); + assert_eq!(expected_models, models); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let test = builder.build(&server).await?; - - test.codex.submit(Op::ListModels).await?; - - let event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::ListModelsResponse(models) => Some(models.clone()), - _ => None, - }) - .await; + let manager = + ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let models = manager.list_models().await; let expected_models = expected_models_for_chatgpt(); - assert_eq!(expected_models, event.models); + assert_eq!(expected_models, models); Ok(()) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 17a167585b..64a5358f35 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -583,7 +583,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::UndoCompleted(_) - | EventMsg::ListModelsResponse(_) | EventMsg::UndoStarted(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 2b3fd07707..55808f17ca 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -307,7 +307,6 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::ContextCompacted(_) - | EventMsg::ListModelsResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index f9a05f9ff6..b99c3bbde1 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -35,11 +35,6 @@ pub enum ReasoningEffort { XHigh, } -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] -pub struct AvailableModelsEvent { - pub models: Vec, -} - /// A reasoning effort option that can be surfaced for a model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ReasoningEffortPreset { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6b787afe17..99d2ec70d3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -19,7 +19,6 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; -use crate::openai_models::AvailableModelsEvent; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; @@ -582,7 +581,6 @@ pub enum EventMsg { AgentMessageContentDelta(AgentMessageContentDeltaEvent), ReasoningContentDelta(ReasoningContentDeltaEvent), ReasoningRawContentDelta(ReasoningRawContentDeltaEvent), - ListModelsResponse(AvailableModelsEvent), } /// Codex errors that we expose to clients. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2ae53bc0c2..c4059dc852 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1842,7 +1842,6 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ListModelsResponse(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } From 8da91d1c8913a29b795132a1b899c88e62e09e3b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 18:00:47 -0800 Subject: [PATCH 017/159] Migrate `tui` to use models manager (#7555) - This PR treats the `ModelsManager` like `AuthManager` and propagate it into the tui, replacing the `builtin_model_presets` - We are also decreasing the visibility of `builtin_model_presets` based on https://github.com/openai/codex/pull/7552 --- codex-rs/core/src/conversation_manager.rs | 4 + .../core/src/openai_models/model_presets.rs | 3 +- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 73 ++++++++++++++----- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 24 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 45 +++++++----- 7 files changed, 107 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 3ac09ec78c..f41e5b597f 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -201,6 +201,10 @@ impl ConversationManager { pub async fn list_models(&self) -> Vec { self.models_manager.available_models.read().await.clone() } + + pub fn get_models_manager(&self) -> Arc { + self.models_manager.clone() + } } /// Return a prefix of `items` obtained by cutting strictly before the nth user message diff --git a/codex-rs/core/src/openai_models/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs index f649d88741..3d46c695cc 100644 --- a/codex-rs/core/src/openai_models/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -209,7 +209,7 @@ static PRESETS: Lazy> = Lazy::new(|| { ] }); -pub fn builtin_model_presets(auth_mode: Option) -> Vec { +pub(crate) fn builtin_model_presets(auth_mode: Option) -> Vec { PRESETS .iter() .filter(|preset| match auth_mode { @@ -220,6 +220,7 @@ pub fn builtin_model_presets(auth_mode: Option) -> Vec { .collect() } +// todo(aibrahim): remove this once we migrate tests pub fn all_model_presets() -> &'static Vec { &PRESETS } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 828255a582..248205c427 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -78,6 +78,8 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", "signal", + "test-util", + "time", ] } tokio-stream = { workspace = true } toml = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index df9e4b5d4b..2367bbd582 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -29,7 +29,7 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_core::openai_models::model_presets::all_model_presets; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; @@ -38,6 +38,7 @@ use codex_core::protocol::TokenUsage; use codex_core::skills::load_skills; use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; @@ -98,12 +99,13 @@ fn should_show_model_migration_prompt( current_model: &str, target_model: &str, hide_prompt_flag: Option, + available_models: Vec, ) -> bool { if target_model == current_model || hide_prompt_flag.unwrap_or(false) { return false; } - all_model_presets() + available_models .iter() .filter(|preset| preset.upgrade.is_some()) .any(|preset| preset.model == current_model) @@ -124,8 +126,10 @@ async fn handle_model_migration_prompt_if_needed( config: &mut Config, app_event_tx: &AppEventSender, auth_mode: Option, + models_manager: Arc, ) -> Option { - let upgrade = all_model_presets() + let available_models = models_manager.available_models.read().await.clone(); + let upgrade = available_models .iter() .find(|preset| preset.model == config.model) .and_then(|preset| preset.upgrade.as_ref()); @@ -142,7 +146,12 @@ async fn handle_model_migration_prompt_if_needed( let target_model = target_model.to_string(); let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key); - if !should_show_model_migration_prompt(&config.model, &target_model, hide_prompt_flag) { + if !should_show_model_migration_prompt( + &config.model, + &target_model, + hide_prompt_flag, + available_models.clone(), + ) { return None; } @@ -200,7 +209,6 @@ pub(crate) struct App { pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, - /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) active_profile: Option, @@ -261,17 +269,21 @@ impl App { let app_event_tx = AppEventSender::new(app_event_tx); let auth_mode = auth_manager.auth().map(|auth| auth.mode); - let exit_info = - handle_model_migration_prompt_if_needed(tui, &mut config, &app_event_tx, auth_mode) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - let conversation_manager = Arc::new(ConversationManager::new( auth_manager.clone(), SessionSource::Cli, )); + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + &app_event_tx, + auth_mode, + conversation_manager.get_models_manager(), + ) + .await; + if let Some(exit_info) = exit_info { + return Ok(exit_info); + } let skills_outcome = load_skills(&config); if !skills_outcome.errors.is_empty() { @@ -305,6 +317,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), skills: skills.clone(), is_first_run, @@ -330,6 +343,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), skills: skills.clone(), is_first_run, @@ -349,7 +363,7 @@ impl App { let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { - server: conversation_manager, + server: conversation_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), @@ -486,6 +500,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, @@ -534,6 +549,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, @@ -1205,28 +1221,41 @@ mod tests { ) } + fn all_model_presets() -> Vec { + codex_core::openai_models::model_presets::all_model_presets().clone() + } + #[test] fn model_migration_prompt_only_shows_for_deprecated_models() { - assert!(should_show_model_migration_prompt("gpt-5", "gpt-5.1", None)); + assert!(should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + None, + all_model_presets() + )); assert!(should_show_model_migration_prompt( "gpt-5-codex", "gpt-5.1-codex", - None + None, + all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5-codex-mini", "gpt-5.1-codex-mini", - None + None, + all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex-max", - None + None, + all_model_presets() )); assert!(!should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex", - None + None, + all_model_presets() )); } @@ -1235,10 +1264,14 @@ mod tests { assert!(!should_show_model_migration_prompt( "gpt-5", "gpt-5.1", - Some(true) + Some(true), + all_model_presets() )); assert!(!should_show_model_migration_prompt( - "gpt-5.1", "gpt-5.1", None + "gpt-5.1", + "gpt-5.1", + None, + all_model_presets() )); } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index a7c8611614..2f59872bce 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -346,6 +346,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c4059dc852..f956ef5c8a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11,6 +11,7 @@ use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -126,7 +127,6 @@ use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; -use codex_core::openai_models::model_presets::builtin_model_presets; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; @@ -256,6 +256,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) skills: Option>, pub(crate) is_first_run: bool, @@ -276,6 +277,7 @@ pub(crate) struct ChatWidget { active_cell: Option>, config: Config, auth_manager: Arc, + models_manager: Arc, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -1232,6 +1234,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, skills, is_first_run, @@ -1257,6 +1260,7 @@ impl ChatWidget { active_cell: None, config: config.clone(), auth_manager, + models_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), @@ -1310,6 +1314,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, skills, .. @@ -1337,6 +1342,7 @@ impl ChatWidget { active_cell: None, config: config.clone(), auth_manager, + models_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), @@ -2025,10 +2031,11 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - builtin_model_presets(auth_mode) - .into_iter() + let models = self.models_manager.available_models.blocking_read(); + models + .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) + .cloned() } fn rate_limit_switch_prompt_hidden(&self) -> bool { @@ -2131,8 +2138,13 @@ impl ChatWidget { /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - let presets: Vec = builtin_model_presets(auth_mode); + let presets: Vec = self + .models_manager + .available_models + .blocking_read() + .iter() + .cloned() + .collect(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 699435a71a..419dab2c87 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -354,6 +354,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), skills: None, is_first_run: true, @@ -390,7 +391,8 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - auth_manager, + auth_manager: auth_manager.clone(), + models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), session_header: SessionHeader::new(cfg.model), initial_user_message: None, token_info: None, @@ -425,6 +427,12 @@ fn make_chatwidget_manual() -> ( (widget, rx, op_rx) } +fn set_chatgpt_auth(chat: &mut ChatWidget) { + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.get_auth_mode())); +} + pub(crate) fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, @@ -881,6 +889,16 @@ fn active_blob(chat: &ChatWidget) -> String { lines_to_single_string(&lines) } +fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { + chat.models_manager + .available_models + .blocking_read() + .iter() + .find(|&preset| preset.model == model) + .cloned() + .unwrap_or_else(|| panic!("{model} preset not found")) +} + #[test] fn empty_enter_during_task_does_not_queue() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1750,13 +1768,11 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1767,13 +1783,11 @@ fn model_reasoning_selection_popup_snapshot() { fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1784,12 +1798,10 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 120); @@ -1872,11 +1884,8 @@ fn reasoning_popup_escape_returns_to_model_popup() { chat.config.model = "gpt-5.1".to_string(); chat.open_model_popup(); - let presets = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex") - .expect("gpt-5.1-codex preset"); - chat.open_reasoning_popup(presets); + let preset = get_available_model(&chat, "gpt-5.1-codex"); + chat.open_reasoning_popup(preset); let before_escape = render_bottom_popup(&chat, 80); assert!(before_escape.contains("Select Reasoning Level")); From cee37a32b2e77393712864316357903a322b3909 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 18:49:47 -0800 Subject: [PATCH 018/159] Migrate model family to models manager (#7565) This PR moves `ModelsFamily` to `openai_models`. It also propagates `ModelsManager` to session services and use it to drive model family. We also make `derive_default_model_family` private because it's a step towards what we want: one place that gives model configuration. This is a second step at having one source of truth for models information and config: `ModelsManager`. Next steps would be to remove `ModelsFamily` from config. That's massive because it's being used in 41 occasions mostly pre launching `codex`. Also, we need to make `find_family_for_model` private. It's also big because it's being used in 21 occasions ~ all tests. --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/client_common.rs | 6 +-- codex-rs/core/src/codex.rs | 34 +++++++++++------ codex-rs/core/src/codex_delegate.rs | 6 +++ codex-rs/core/src/config/mod.rs | 17 ++++----- codex-rs/core/src/conversation_manager.rs | 20 ++++++++-- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/openai_model_info.rs | 2 +- codex-rs/core/src/openai_models/mod.rs | 1 + .../src/{ => openai_models}/model_family.rs | 17 +++++---- .../core/src/openai_models/models_manager.rs | 6 +++ codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tasks/mod.rs | 5 +++ codex-rs/core/src/tasks/review.rs | 1 + codex-rs/core/src/tools/spec.rs | 37 +++++++------------ codex-rs/core/tests/common/test_codex.rs | 2 - codex-rs/core/tests/suite/client.rs | 5 +-- codex-rs/core/tests/suite/prompt_caching.rs | 3 +- .../core/tests/suite/shell_serialization.rs | 13 +++---- codex-rs/tui/src/app.rs | 11 ++---- 20 files changed, 109 insertions(+), 82 deletions(-) rename codex-rs/core/src/{ => openai_models}/model_family.rs (95%) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f4248f30ab..a3c990cdba 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -46,10 +46,10 @@ use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; +use crate::openai_models::model_family::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index a249ca6fcc..f6152e55d7 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,6 +1,6 @@ use crate::client_common::tools::ToolSpec; use crate::error::Result; -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; pub use codex_api::common::ResponseEvent; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::models::ResponseItem; @@ -252,7 +252,7 @@ impl Stream for ResponseStream { #[cfg(test)] mod tests { - use crate::model_family::find_family_for_model; + use crate::openai_models::model_family::find_family_for_model; use codex_api::ResponsesApiRequest; use codex_api::common::OpenAiVerbosity; use codex_api::common::TextControls; @@ -309,7 +309,7 @@ mod tests { }, ]; for test_case in test_cases { - let model_family = find_family_for_model(test_case.slug).expect("known model slug"); + let model_family = find_family_for_model(test_case.slug); let expected = if test_case.expects_apply_patch_instructions { format!( "{}\n{}", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 885a4cdf74..ba7c69eb95 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::features::Feature; use crate::features::Features; use crate::function_tool::FunctionCallError; +use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::response_processing::process_items; @@ -74,7 +75,6 @@ use crate::error::Result as CodexResult; use crate::exec::StreamOutput; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; -use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; @@ -163,6 +163,7 @@ impl Codex { pub async fn spawn( config: Config, auth_manager: Arc, + models_manager: Arc, conversation_history: InitialHistory, session_source: SessionSource, ) -> CodexResult { @@ -200,6 +201,7 @@ impl Codex { session_configuration, config.clone(), auth_manager.clone(), + models_manager.clone(), tx_event.clone(), conversation_history, session_source_clone, @@ -394,6 +396,7 @@ pub(crate) struct SessionSettingsUpdate { impl Session { fn make_turn_context( auth_manager: Option>, + models_manager: &ModelsManager, otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, @@ -402,8 +405,7 @@ impl Session { ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); let features = &config.features; - let model_family = find_family_for_model(&session_configuration.model) - .unwrap_or_else(|| config.model_family.clone()); + let model_family = models_manager.construct_model_family(&session_configuration.model); let mut per_turn_config = (*config).clone(); per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_family = model_family.clone(); @@ -459,6 +461,7 @@ impl Session { session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, + models_manager: Arc, tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, @@ -570,6 +573,7 @@ impl Session { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, + models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), }; @@ -756,6 +760,7 @@ impl Session { let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), + &self.services.models_manager, &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -1824,8 +1829,7 @@ async fn spawn_review_thread( resolved: crate::review_prompts::ResolvedReviewRequest, ) { let model = config.review_model.clone(); - let review_model_family = find_family_for_model(&model) - .unwrap_or_else(|| parent_turn_context.client.get_model_family()); + let review_model_family = sess.services.models_manager.construct_model_family(&model); // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -2083,13 +2087,13 @@ async fn run_turn( let mut base_instructions = turn_context.base_instructions.clone(); if parallel_tool_calls { static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - if let Some(family) = - find_family_for_model(&sess.state.lock().await.session_configuration.model) - { - let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); - new_instructions.push_str(INSTRUCTIONS); - base_instructions = Some(new_instructions); - } + let family = sess + .services + .models_manager + .construct_model_family(&sess.state.lock().await.session_configuration.model); + let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); + new_instructions.push_str(INSTRUCTIONS); + base_instructions = Some(new_instructions); } let prompt = Prompt { input, @@ -2751,6 +2755,7 @@ mod tests { false, config.cli_auth_credentials_store_mode, ); + let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2781,11 +2786,13 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + models_manager: models_manager.clone(), tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), + &models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -2829,6 +2836,7 @@ mod tests { false, config.cli_auth_credentials_store_mode, ); + let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2859,11 +2867,13 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + models_manager: models_manager.clone(), tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), + &models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 796331d1e8..b6e4c88f30 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -25,6 +25,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::error::CodexErr; +use crate::openai_models::models_manager::ModelsManager; use codex_protocol::protocol::InitialHistory; /// Start an interactive sub-Codex conversation and return IO channels. @@ -35,6 +36,7 @@ use codex_protocol::protocol::InitialHistory; pub(crate) async fn run_codex_conversation_interactive( config: Config, auth_manager: Arc, + models_manager: Arc, parent_session: Arc, parent_ctx: Arc, cancel_token: CancellationToken, @@ -46,6 +48,7 @@ pub(crate) async fn run_codex_conversation_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn( config, auth_manager, + models_manager, initial_history.unwrap_or(InitialHistory::New), SessionSource::SubAgent(SubAgentSource::Review), ) @@ -88,9 +91,11 @@ pub(crate) async fn run_codex_conversation_interactive( /// Convenience wrapper for one-time use with an initial prompt. /// /// Internally calls the interactive variant, then immediately submits the provided input. +#[allow(clippy::too_many_arguments)] pub(crate) async fn run_codex_conversation_one_shot( config: Config, auth_manager: Arc, + models_manager: Arc, input: Vec, parent_session: Arc, parent_ctx: Arc, @@ -103,6 +108,7 @@ pub(crate) async fn run_codex_conversation_one_shot( let io = run_codex_conversation_interactive( config, auth_manager, + models_manager, parent_session, parent_ctx, child_cancel.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dccf0556f1..185584115b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -22,14 +22,13 @@ use crate::features::FeatureOverrides; use crate::features::Features; use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; -use crate::model_family::ModelFamily; -use crate::model_family::derive_default_model_family; -use crate::model_family::find_family_for_model; use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; +use crate::openai_models::model_family::ModelFamily; +use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; @@ -82,6 +81,7 @@ pub struct Config { /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, + // todo(aibrahim): remove this field pub model_family: ModelFamily, /// Size of the context window for the model, in tokens. @@ -1109,8 +1109,7 @@ impl Config { .or(cfg.model) .unwrap_or_else(default_model); - let mut model_family = - find_family_for_model(&model).unwrap_or_else(|| derive_default_model_family(&model)); + let mut model_family = find_family_for_model(&model); if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries { model_family.supports_reasoning_summaries = supports_reasoning_summaries; @@ -2955,7 +2954,7 @@ model_verbosity = "high" Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3").expect("known model slug"), + model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -3029,7 +3028,7 @@ model_verbosity = "high" let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), + model_family: find_family_for_model("gpt-3.5-turbo"), model_context_window: Some(16_385), model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), @@ -3118,7 +3117,7 @@ model_verbosity = "high" let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3").expect("known model slug"), + model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -3193,7 +3192,7 @@ model_verbosity = "high" let expected_gpt5_profile_config = Config { model: "gpt-5.1".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-5.1").expect("known model slug"), + model_family: find_family_for_model("gpt-5.1"), model_context_window: Some(272_000), model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index f41e5b597f..22f73dfe12 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -65,14 +65,19 @@ impl ConversationManager { } pub async fn new_conversation(&self, config: Config) -> CodexResult { - self.spawn_conversation(config, self.auth_manager.clone()) - .await + self.spawn_conversation( + config, + self.auth_manager.clone(), + self.models_manager.clone(), + ) + .await } async fn spawn_conversation( &self, config: Config, auth_manager: Arc, + models_manager: Arc, ) -> CodexResult { let CodexSpawnOk { codex, @@ -80,6 +85,7 @@ impl ConversationManager { } = Codex::spawn( config, auth_manager, + models_manager, InitialHistory::New, self.session_source.clone(), ) @@ -156,6 +162,7 @@ impl ConversationManager { } = Codex::spawn( config, auth_manager, + self.models_manager.clone(), initial_history, self.session_source.clone(), ) @@ -193,7 +200,14 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = Codex::spawn(config, auth_manager, history, self.session_source.clone()).await?; + } = Codex::spawn( + config, + auth_manager, + self.models_manager.clone(), + history, + self.session_source.clone(), + ) + .await?; self.finalize_spawn(codex, conversation_id).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d32366476a..39dc224dd8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -67,7 +67,6 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; -pub mod model_family; mod openai_model_info; pub mod project_doc; mod rollout; diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index 96f3ed77cb..4ee7d7187a 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -1,4 +1,4 @@ -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; // Shared constants for commonly used window/token sizes. pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index 13ee2e0605..e7a8beddb1 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1,2 +1,3 @@ +pub mod model_family; pub mod model_presets; pub mod models_manager; diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs similarity index 95% rename from codex-rs/core/src/model_family.rs rename to codex-rs/core/src/openai_models/model_family.rs index 0417f13b12..1580ab137c 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -8,11 +8,11 @@ use crate::truncate::TruncationPolicy; /// The `instructions` field in the payload sent to a model should always start /// with this content. -const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); +const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); -const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md"); -const GPT_5_1_INSTRUCTIONS: &str = include_str!("../gpt_5_1_prompt.md"); -const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md"); +const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md"); +const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); +const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -100,13 +100,14 @@ macro_rules! model_family { $( mf.$key = $value; )* - Some(mf) + mf }}; } +// todo(aibrahim): remove this function /// Returns a `ModelFamily` for the given model slug, or `None` if the slug /// does not match any known model family. -pub fn find_family_for_model(slug: &str) -> Option { +pub fn find_family_for_model(slug: &str) -> ModelFamily { if slug.starts_with("o3") { model_family!( slug, "o3", @@ -238,11 +239,11 @@ pub fn find_family_for_model(slug: &str) -> Option { truncation_policy: TruncationPolicy::Bytes(10_000), ) } else { - None + derive_default_model_family(slug) } } -pub fn derive_default_model_family(model: &str) -> ModelFamily { +fn derive_default_model_family(model: &str) -> ModelFamily { ModelFamily { slug: model.to_string(), family: model.to_string(), diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 1d57f1e693..c6f9365429 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -2,6 +2,8 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelPreset; use tokio::sync::RwLock; +use crate::openai_models::model_family::ModelFamily; +use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; pub struct ModelsManager { @@ -23,4 +25,8 @@ impl ModelsManager { let models = builtin_model_presets(self.auth_mode); *self.available_models.write().await = models; } + + pub fn construct_model_family(&self, model: &str) -> ModelFamily { + find_family_for_model(model) + } } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 287fb73d25..a35720a9bf 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; +use crate::openai_models::models_manager::ModelsManager; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -20,6 +21,7 @@ pub(crate) struct SessionServices { pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, pub(crate) tool_approvals: Mutex, } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9bda02c34e..fa5433ef5e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -19,6 +19,7 @@ use tracing::warn; use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; +use crate::openai_models::models_manager::ModelsManager; use crate::protocol::EventMsg; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnAbortReason; @@ -55,6 +56,10 @@ impl SessionTaskContext { pub(crate) fn auth_manager(&self) -> Arc { Arc::clone(&self.session.services.auth_manager) } + + pub(crate) fn models_manager(&self) -> Arc { + Arc::clone(&self.session.services.models_manager) + } } /// Async task that drives a [`Session`] turn. diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index a6ec840a84..738d33c529 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -93,6 +93,7 @@ async fn start_review_conversation( (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), + session.models_manager(), input, session.clone_session(), ctx.clone(), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d07c605e50..2c3aa2d442 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,7 +2,7 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::features::Feature; use crate::features::Features; -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::ApplyPatchToolType; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; @@ -1118,7 +1118,7 @@ pub(crate) fn build_specs( #[cfg(test)] mod tests { use crate::client_common::tools::FreeformTool; - use crate::model_family::find_family_for_model; + use crate::openai_models::model_family::find_family_for_model; use crate::tools::registry::ConfiguredToolSpec; use mcp_types::ToolInputSchema; use pretty_assertions::assert_eq; @@ -1213,8 +1213,7 @@ mod tests { #[test] fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1273,8 +1272,7 @@ mod tests { } fn assert_model_tools(model_family: &str, features: &Features, expected_tools: &[&str]) { - let model_family = find_family_for_model(model_family) - .unwrap_or_else(|| panic!("{model_family} should be a valid model family")); + let model_family = find_family_for_model(model_family); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, features, @@ -1466,7 +1464,7 @@ mod tests { #[test] fn test_build_specs_default_shell_present() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); features.enable(Feature::UnifiedExec); @@ -1487,8 +1485,7 @@ mod tests { #[test] #[ignore] fn test_parallel_support_flags() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("codex-mini-latest should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.disable(Feature::ViewImageTool); features.enable(Feature::UnifiedExec); @@ -1507,8 +1504,7 @@ mod tests { #[test] fn test_test_model_family_includes_sync_tool() { - let model_family = find_family_for_model("test-gpt-5-codex") - .expect("test-gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("test-gpt-5-codex"); let mut features = Features::with_defaults(); features.disable(Feature::ViewImageTool); let config = ToolsConfig::new(&ToolsConfigParams { @@ -1537,7 +1533,7 @@ mod tests { #[test] fn test_build_specs_mcp_tools_converted() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1631,7 +1627,7 @@ mod tests { #[test] fn test_build_specs_mcp_tools_sorted_by_name() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { @@ -1706,8 +1702,7 @@ mod tests { #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1763,8 +1758,7 @@ mod tests { #[test] fn test_mcp_tool_integer_normalized_to_number() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1816,8 +1810,7 @@ mod tests { #[test] fn test_mcp_tool_array_without_items_gets_default_string_items() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1873,8 +1866,7 @@ mod tests { #[test] fn test_mcp_tool_anyof_defaults_to_string() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1985,8 +1977,7 @@ Examples of valid command strings: #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1c5919214f..aff2ab60df 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -11,7 +11,6 @@ use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -71,7 +70,6 @@ impl TestCodexBuilder { let new_model = model.to_string(); self.with_config(move |config| { config.model = new_model.clone(); - config.model_family = find_family_for_model(&new_model).expect("model family"); }) } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e0e06757b5..e5d3d7e61c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,7 +16,7 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::error::CodexErr; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; @@ -1378,8 +1378,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.model = "gpt-5.1".to_string(); - config.model_family = - find_family_for_model("gpt-5.1").expect("known gpt-5.1 model family"); + config.model_family = find_family_for_model("gpt-5.1"); config.model_context_window = Some(272_000); }) .build(&server) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 0c908e35be..219f29e2fa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; @@ -74,7 +74,6 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { config.features.disable(Feature::ApplyPatchFreeform); config.model = "codex-mini-latest".to_string(); config.model_family = find_family_for_model("codex-mini-latest") - .expect("model family for codex-mini-latest"); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 77c835b320..5dbdda4fcc 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -4,7 +4,7 @@ use anyhow::Result; use codex_core::config::Config; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::SandboxPolicy; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; @@ -46,13 +46,12 @@ fn configure_shell_command_model(output_type: ShellModelOutput, config: &mut Con return; } - if let Some(shell_command_family) = find_family_for_model("test-gpt-5-codex") { - if config.model_family.shell_type == shell_command_family.shell_type { - return; - } - config.model = shell_command_family.slug.clone(); - config.model_family = shell_command_family; + let shell_command_family = find_family_for_model("test-gpt-5-codex"); + if config.model_family.shell_type == shell_command_family.shell_type { + return; } + config.model = shell_command_family.slug.clone(); + config.model_family = shell_command_family; } fn shell_responses( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2367bbd582..ff5fe9a540 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,7 +26,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::models_manager::ModelsManager; @@ -162,9 +162,7 @@ async fn handle_model_migration_prompt_if_needed( migration_config: migration_config_key.to_string(), }); config.model = target_model.to_string(); - if let Some(family) = find_family_for_model(&target_model) { - config.model_family = family; - } + config.model_family = find_family_for_model(&target_model); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -683,9 +681,8 @@ impl App { AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); self.config.model = model.clone(); - if let Some(family) = find_family_for_model(&model) { - self.config.model_family = family; - } + let family = find_family_for_model(&model); + self.config.model_family = family; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); From 3e6cd5660cea363c7d3701cb48fcdfde84f07aca Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 3 Dec 2025 19:08:18 -0800 Subject: [PATCH 019/159] [app-server] make `file_path` for config optional (#7560) When we are writing to config using `config/value/write` or `config/batchWrite`, it always require a `config/read` before it right now in order to get the correct file path to write to. make this optional so we read from the default user config file if this is not passed in. --- .../app-server-protocol/src/protocol/v2.rs | 8 ++- codex-rs/app-server/src/config_api.rs | 55 +++++++++++++++---- .../app-server/tests/suite/v2/config_rpc.rs | 22 +++++++- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f1d8392135..22d53c3dee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -209,6 +209,8 @@ pub struct OverriddenMetadata { pub struct ConfigWriteResponse { pub status: WriteStatus, pub version: String, + /// Canonical path to the config file that was written. + pub file_path: String, pub overridden_metadata: Option, } @@ -245,10 +247,11 @@ pub struct ConfigReadResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigValueWriteParams { - pub file_path: String, pub key_path: String, pub value: JsonValue, pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + pub file_path: Option, pub expected_version: Option, } @@ -256,8 +259,9 @@ pub struct ConfigValueWriteParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigBatchWriteParams { - pub file_path: String, pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + pub file_path: Option, pub expected_version: Option, } diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 68bbdd8c66..ae02927f7a 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -109,12 +109,17 @@ impl ConfigApi { async fn apply_edits( &self, - file_path: String, + file_path: Option, expected_version: Option, edits: Vec<(String, JsonValue, MergeStrategy)>, ) -> Result { let allowed_path = self.codex_home.join(CONFIG_FILE_NAME); - if !paths_match(&allowed_path, &file_path) { + let provided_path = file_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| allowed_path.clone()); + + if !paths_match(&allowed_path, &provided_path) { return Err(config_write_error( ConfigWriteErrorCode::ConfigLayerReadonly, "Only writes to the user config are allowed", @@ -190,9 +195,16 @@ impl ConfigApi { .map(|_| WriteStatus::OkOverridden) .unwrap_or(WriteStatus::Ok); + let file_path = provided_path + .canonicalize() + .unwrap_or(provided_path.clone()) + .display() + .to_string(); + Ok(ConfigWriteResponse { status, version: updated_layers.user.version.clone(), + file_path, overridden_metadata: overridden, }) } @@ -587,15 +599,14 @@ fn canonical_json(value: &JsonValue) -> JsonValue { } } -fn paths_match(expected: &Path, provided: &str) -> bool { - let provided_path = PathBuf::from(provided); +fn paths_match(expected: &Path, provided: &Path) -> bool { if let (Ok(expanded_expected), Ok(expanded_provided)) = - (expected.canonicalize(), provided_path.canonicalize()) + (expected.canonicalize(), provided.canonicalize()) { return expanded_expected == expanded_provided; } - expected == provided_path + expected == provided } fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { @@ -795,7 +806,7 @@ mod tests { let result = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("never"), merge_strategy: MergeStrategy::Replace, @@ -832,7 +843,7 @@ mod tests { let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); let error = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "model".to_string(), value: json!("gpt-5"), merge_strategy: MergeStrategy::Replace, @@ -852,6 +863,30 @@ mod tests { ); } + #[tokio::test] + async fn write_value_defaults_to_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap(); + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + assert!( + contents.contains("model = \"gpt-new\""), + "config.toml should be updated even when file_path is omitted" + ); + } + #[tokio::test] async fn invalid_user_value_rejected_even_if_overridden_by_managed() { let tmp = tempdir().expect("tempdir"); @@ -872,7 +907,7 @@ mod tests { let error = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("bogus"), merge_strategy: MergeStrategy::Replace, @@ -957,7 +992,7 @@ mod tests { let result = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("on-request"), merge_strategy: MergeStrategy::Replace, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 343a13c3c4..eb3ece64b2 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -206,7 +206,7 @@ model = "gpt-old" let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: None, key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, @@ -219,8 +219,16 @@ model = "gpt-old" ) .await??; let write: ConfigWriteResponse = to_response(write_resp)?; + let expected_file_path = codex_home + .path() + .join("config.toml") + .canonicalize() + .unwrap() + .display() + .to_string(); assert_eq!(write.status, WriteStatus::Ok); + assert_eq!(write.file_path, expected_file_path); assert!(write.overridden_metadata.is_none()); let verify_id = mcp @@ -254,7 +262,7 @@ model = "gpt-old" let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: Some(codex_home.path().join("config.toml").display().to_string()), key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, @@ -288,7 +296,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: Some(codex_home.path().join("config.toml").display().to_string()), edits: vec![ ConfigEdit { key_path: "sandbox_mode".to_string(), @@ -314,6 +322,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { .await??; let batch_write: ConfigWriteResponse = to_response(batch_resp)?; assert_eq!(batch_write.status, WriteStatus::Ok); + let expected_file_path = codex_home + .path() + .join("config.toml") + .canonicalize() + .unwrap() + .display() + .to_string(); + assert_eq!(batch_write.file_path, expected_file_path); let read_id = mcp .send_config_read_request(ConfigReadParams { From edd98dd3b7d1d96f61801382ab4319e5bc36aefa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 3 Dec 2025 21:10:54 -0600 Subject: [PATCH 020/159] Remove test from #7481 that doesn't add much value (#7558) Follow-up from PR #7481 --- codex-rs/tui/src/pager_overlay.rs | 43 ------------------------------- 1 file changed, 43 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b5f7b963cc..3b47e9a70e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -748,49 +748,6 @@ mod tests { assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } - #[test] - fn transcript_overlay_wraps_long_exec_output_lines() { - let marker = "Z"; - let long_line = marker.repeat(200); - - let mut exec_cell = crate::exec_cell::new_active_exec_command( - "exec-long".into(), - vec!["bash".into(), "-lc".into(), "echo long".into()], - vec![ParsedCommand::Unknown { - cmd: "echo long".into(), - }], - ExecCommandSource::Agent, - None, - false, - ); - exec_cell.complete_call( - "exec-long", - CommandOutput { - exit_code: 0, - aggregated_output: format!("{long_line}\n"), - formatted_output: long_line, - }, - Duration::from_millis(10), - ); - let exec_cell: Arc = Arc::new(exec_cell); - - let mut overlay = TranscriptOverlay::new(vec![exec_cell]); - let area = Rect::new(0, 0, 20, 10); - let mut buf = Buffer::empty(area); - - overlay.render(area, &mut buf); - let rendered = buffer_to_text(&buf, area); - - let wrapped_lines = rendered - .lines() - .filter(|line| line.contains(marker)) - .count(); - assert!( - wrapped_lines >= 2, - "expected long exec output to wrap into multiple lines in transcript overlay, got:\n{rendered}" - ); - } - #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( From 67e67e054fa70b6963c4b6f03f751e42bfbfba43 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 20:54:48 -0800 Subject: [PATCH 021/159] Migrate codex max (#7566) - make codex max the default - fix: we were doing some async work in sync function which caused tui to panic --- codex-rs/core/src/config/mod.rs | 5 ++--- .../core/src/openai_models/model_presets.rs | 18 ++--------------- codex-rs/core/tests/suite/list_models.rs | 7 ++++++- codex-rs/tui/src/app.rs | 4 ++-- codex-rs/tui/src/chatwidget.rs | 20 +++++++++++-------- ...twidget__tests__model_selection_popup.snap | 8 +++++--- 6 files changed, 29 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 185584115b..def63df845 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -61,9 +61,8 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex"; -const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex"; -pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex"; +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of diff --git a/codex-rs/core/src/openai_models/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs index 3d46c695cc..6c00465694 100644 --- a/codex-rs/core/src/openai_models/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -209,13 +209,10 @@ static PRESETS: Lazy> = Lazy::new(|| { ] }); -pub(crate) fn builtin_model_presets(auth_mode: Option) -> Vec { +pub(crate) fn builtin_model_presets(_auth_mode: Option) -> Vec { PRESETS .iter() - .filter(|preset| match auth_mode { - Some(AuthMode::ApiKey) => preset.show_in_picker && preset.id != "gpt-5.1-codex-max", - _ => preset.show_in_picker, - }) + .filter(|preset| preset.show_in_picker) .cloned() .collect() } @@ -228,21 +225,10 @@ pub fn all_model_presets() -> &'static Vec { #[cfg(test)] mod tests { use super::*; - use codex_app_server_protocol::AuthMode; #[test] fn only_one_default_model_is_configured() { let default_models = PRESETS.iter().filter(|preset| preset.is_default).count(); assert!(default_models == 1); } - - #[test] - fn gpt_5_1_codex_max_hidden_for_api_key_auth() { - let presets = builtin_model_presets(Some(AuthMode::ApiKey)); - assert!( - presets - .iter() - .all(|preset| preset.id != "gpt-5.1-codex-max") - ); - } } diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 9303820163..6348841c6f 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -30,7 +30,12 @@ async fn list_models_returns_chatgpt_models() -> Result<()> { } fn expected_models_for_api_key() -> Vec { - vec![gpt_5_1_codex(), gpt_5_1_codex_mini(), gpt_5_1()] + vec![ + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + ] } fn expected_models_for_chatgpt() -> Vec { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff5fe9a540..6120c7978d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -63,7 +63,7 @@ use tokio::sync::mpsc::unbounded_channel; use crate::history_cell::UpdateAvailableHistoryCell; const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; -const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -1438,7 +1438,7 @@ mod tests { Some(AuthMode::ChatGPT), HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); - assert!(!migration_prompt_allows_auth_mode( + assert!(migration_prompt_allows_auth_mode( Some(AuthMode::ApiKey), HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f956ef5c8a..c257f7c1bd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2031,7 +2031,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.available_models.blocking_read(); + let models = self.models_manager.available_models.try_read().ok()?; models .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) @@ -2138,13 +2138,17 @@ impl ChatWidget { /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); - let presets: Vec = self - .models_manager - .available_models - .blocking_read() - .iter() - .cloned() - .collect(); + let presets: Vec = + // todo(aibrahim): make this async function + if let Ok(models) = self.models_manager.available_models.try_read() { + models.clone() + } else { + self.add_info_message( + "Models are being updated; please try /model again in a moment.".to_string(), + None, + ); + return; + }; let mut items: Vec = Vec::new(); for preset in presets.into_iter() { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 6cfce48b89..56a209ef73 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -5,9 +5,11 @@ expression: popup Select Model and Effort Access legacy models by running codex -m or in your config.toml -› 1. gpt-5.1-codex Optimized for codex. - 2. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less capable. - 3. gpt-5.1 Broad world knowledge with strong general reasoning. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. Press enter to select reasoning effort, or esc to dismiss. From ccdeb9d9c4f96c18be9f3e0702e0e00b58ac0887 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:58:35 -0800 Subject: [PATCH 022/159] use markdown for rendering tips (#7557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - render tooltip content through the markdown renderer and prepend a bold Tip label - wrap tooltips at the available width using the indent’s measured width before adding the indent ## Testing - `/root/.cargo/bin/just fmt` - `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1 /root/.cargo/bin/just fix -p codex-tui` *(fails: codex-tui tests reference tokio::time::advance/start_paused gated behind the tokio test-util feature)* - `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1 cargo test -p codex-tui` *(fails: codex-tui tests reference tokio::time::advance/start_paused gated behind the tokio test-util feature)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_693081406050832c9772ae9fa5dd77ca) --- codex-rs/tui/src/history_cell.rs | 23 ++++++++++++----------- codex-rs/tui/tooltips.txt | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c4fd31f548..de8c06488e 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -573,18 +573,19 @@ impl TooltipHistoryCell { impl HistoryCell for TooltipHistoryCell { fn display_lines(&self, width: u16) -> Vec> { - let indent: Line<'static> = " ".into(); - let mut lines = Vec::new(); - let tooltip_line: Line<'static> = vec!["Tip: ".cyan(), self.tip.into()].into(); - let wrap_opts = RtOptions::new(usize::from(width.max(1))) - .initial_indent(indent.clone()) - .subsequent_indent(indent.clone()); - lines.extend( - word_wrap_line(&tooltip_line, wrap_opts.clone()) - .into_iter() - .map(|line| line_to_static(&line)), + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let wrap_width = usize::from(width.max(1)) + .saturating_sub(indent_width) + .max(1); + let mut lines: Vec> = Vec::new(); + append_markdown( + &format!("**Tip:** {}", self.tip), + Some(wrap_width), + &mut lines, ); - lines + + prefix_lines(lines, indent.into(), indent.into()) } } diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 09167eb4fc..70c254d293 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -8,4 +8,4 @@ Type / to open the command popup; Tab autocompletes slash commands and saved pro Use /prompts: key=value to expand a saved prompt with placeholders before sending. With the composer empty, press Esc to step back and edit your last message; Enter confirms. Paste an image with Ctrl+V to attach it to your next message. -You can resume a previous conversation by doing `codex resume` \ No newline at end of file +You can resume a previous conversation by running `codex resume` From e925a380dc960b4c47e5015ad8bdfd6ac25b63ce Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:17:02 -0500 Subject: [PATCH 023/159] whitelist command prefix integration in core and tui (#7033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this PR enables TUI to approve commands and add their prefixes to an allowlist: Screenshot 2025-11-21 at 4 18 07 PM note: we only show the option to whitelist the command when 1) command is not multi-part (e.g `git add -A && git commit -m 'hello world'`) 2) command is not already matched by an existing rule --- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/apply_patch.rs | 4 +- codex-rs/core/src/codex.rs | 69 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/exec_policy.rs | 377 ++++++++++++++---- codex-rs/core/src/tools/handlers/shell.rs | 8 +- codex-rs/core/src/tools/orchestrator.rs | 28 +- .../core/src/tools/runtimes/apply_patch.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 22 +- .../core/src/tools/runtimes/unified_exec.rs | 29 +- codex-rs/core/src/tools/sandboxing.rs | 42 +- .../core/src/unified_exec/session_manager.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 149 ++++++- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/otel/src/otel_event_manager.rs | 4 +- codex-rs/protocol/src/approvals.rs | 4 + codex-rs/protocol/src/protocol.rs | 8 +- .../tui/src/bottom_pane/approval_overlay.rs | 87 +++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + ...hatwidget__tests__approval_modal_exec.snap | 5 +- ..._tests__approval_modal_exec_no_reason.snap | 5 +- ...dget__tests__exec_approval_modal_exec.snap | 6 +- ...sts__status_widget_and_approval_modal.snap | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 6 + codex-rs/tui/src/history_cell.rs | 13 + docs/config.md | 2 +- 27 files changed, 733 insertions(+), 155 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index df4cdb8980..6d03a9f5db 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -179,6 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, + allow_prefix: _allow_prefix, parsed_cmd, }) => match api_version { ApiVersion::V1 => { diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index dffe94be61..5cf0ecee70 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -70,7 +70,9 @@ pub(crate) async fn apply_patch( ) .await; match rx_approve.await.unwrap_or_default() { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, user_explicitly_approved_this_action: true, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ba7c69eb95..7e0a1d685d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -73,6 +73,7 @@ use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; +use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_model_info::get_model_info; @@ -293,7 +294,7 @@ pub(crate) struct TurnContext { pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, - pub(crate) exec_policy: Arc, + pub(crate) exec_policy: Arc>, pub(crate) truncation_policy: TruncationPolicy, } @@ -349,7 +350,7 @@ pub(crate) struct SessionConfiguration { cwd: PathBuf, /// Execpolicy policy, applied only when enabled by feature flag. - exec_policy: Arc, + exec_policy: Arc>, // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, @@ -870,11 +871,48 @@ impl Session { .await } + /// Adds a prefix rule to the exec policy + /// + /// This mutates the in-memory execpolicy so the current conversation can use the new + /// prefix and persists the change in default.execpolicy so new conversations will also allow the new prefix. + pub(crate) async fn persist_command_allow_prefix( + &self, + prefix: &[String], + ) -> Result<(), ExecPolicyUpdateError> { + let features = self.features.clone(); + let (codex_home, current_policy) = { + let state = self.state.lock().await; + ( + state + .session_configuration + .original_config_do_not_use + .codex_home + .clone(), + state.session_configuration.exec_policy.clone(), + ) + }; + + if !features.enabled(Feature::ExecPolicy) { + error!("attempted to append execpolicy rule while execpolicy feature is disabled"); + return Err(ExecPolicyUpdateError::FeatureDisabled); + } + + crate::exec_policy::append_allow_prefix_rule_and_update( + &codex_home, + ¤t_policy, + prefix, + ) + .await?; + + Ok(()) + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `sub_id`/`call_id` so matching responses are delivered /// to the correct in-flight turn. If the task is aborted, this returns the /// default `ReviewDecision` (`Denied`). + #[allow(clippy::too_many_arguments)] pub async fn request_command_approval( &self, turn_context: &TurnContext, @@ -883,6 +921,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: Option, + allow_prefix: Option>, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -910,6 +949,7 @@ impl Session { cwd, reason, risk, + allow_prefix, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -1079,6 +1119,10 @@ impl Session { self.features.enabled(feature) } + pub(crate) fn features(&self) -> Features { + self.features.clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -1513,6 +1557,7 @@ mod handlers { use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; @@ -1627,7 +1672,21 @@ mod handlers { } } + /// Propagate a user's exec approval decision to the session + /// Also optionally whitelists command in execpolicy pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { + if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision + && let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await + { + let message = format!("Failed to update execpolicy allow list: {err}"); + tracing::warn!("{message}"); + let warning = EventMsg::Warning(WarningEvent { message }); + sess.send_event_raw(Event { + id: id.clone(), + msg: warning, + }) + .await; + } match decision { ReviewDecision::Abort => { sess.interrupt_task().await; @@ -2571,7 +2630,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; @@ -2770,7 +2829,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; @@ -2851,7 +2910,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b6e4c88f30..1606855669 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -281,6 +281,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, + event.allow_prefix, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 602e5d679e..7c96deea90 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -4,25 +4,31 @@ use std::path::PathBuf; use std::sync::Arc; use crate::command_safety::is_dangerous_command::requires_initial_appoval; +use codex_execpolicy::AmendError; use codex_execpolicy::Decision; +use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; use tokio::fs; +use tokio::sync::RwLock; +use tokio::task::spawn_blocking; use crate::bash::parse_shell_lc_plain_commands; use crate::features::Feature; use crate::features::Features; use crate::sandboxing::SandboxPermissions; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; +const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; #[derive(Debug, Error)] pub enum ExecPolicyError { @@ -45,12 +51,30 @@ pub enum ExecPolicyError { }, } +#[derive(Debug, Error)] +pub enum ExecPolicyUpdateError { + #[error("failed to update execpolicy file {path}: {source}")] + AppendRule { path: PathBuf, source: AmendError }, + + #[error("failed to join blocking execpolicy update task: {source}")] + JoinBlockingTask { source: tokio::task::JoinError }, + + #[error("failed to update in-memory execpolicy: {source}")] + AddRule { + #[from] + source: ExecPolicyRuleError, + }, + + #[error("cannot append execpolicy rule because execpolicy feature is disabled")] + FeatureDisabled, +} + pub(crate) async fn exec_policy_for( features: &Features, codex_home: &Path, -) -> Result, ExecPolicyError> { +) -> Result>, ExecPolicyError> { if !features.enabled(Feature::ExecPolicy) { - return Ok(Arc::new(Policy::empty())); + return Ok(Arc::new(RwLock::new(Policy::empty()))); } let policy_dir = codex_home.join(POLICY_DIR_NAME); @@ -74,7 +98,7 @@ pub(crate) async fn exec_policy_for( })?; } - let policy = Arc::new(parser.build()); + let policy = Arc::new(RwLock::new(parser.build())); tracing::debug!( "loaded execpolicy from {} files in {}", policy_paths.len(), @@ -84,58 +108,107 @@ pub(crate) async fn exec_policy_for( Ok(policy) } -fn evaluate_with_policy( - policy: &Policy, - command: &[String], - approval_policy: AskForApproval, -) -> Option { - let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = policy.check_multiple(commands.iter()); +pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { + codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) +} - match evaluation { - Evaluation::Match { decision, .. } => match decision { - Decision::Forbidden => Some(ApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }), - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - Some(ApprovalRequirement::Forbidden { reason }) - } else { - Some(ApprovalRequirement::NeedsApproval { - reason: Some(reason), - }) +pub(crate) async fn append_allow_prefix_rule_and_update( + codex_home: &Path, + current_policy: &Arc>, + prefix: &[String], +) -> Result<(), ExecPolicyUpdateError> { + let policy_path = default_policy_path(codex_home); + let prefix = prefix.to_vec(); + spawn_blocking({ + let policy_path = policy_path.clone(); + let prefix = prefix.clone(); + move || blocking_append_allow_prefix_rule(&policy_path, &prefix) + }) + .await + .map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })? + .map_err(|source| ExecPolicyUpdateError::AppendRule { + path: policy_path, + source, + })?; + + current_policy + .write() + .await + .add_prefix_rule(&prefix, Decision::Allow)?; + + Ok(()) +} + +fn requirement_from_decision( + decision: Decision, + approval_policy: AskForApproval, +) -> ExecApprovalRequirement { + match decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + let reason = PROMPT_REASON.to_string(); + if matches!(approval_policy, AskForApproval::Never) { + ExecApprovalRequirement::Forbidden { reason } + } else { + ExecApprovalRequirement::NeedsApproval { + reason: Some(reason), + allow_prefix: None, } } - Decision::Allow => Some(ApprovalRequirement::Skip { - bypass_sandbox: true, - }), + } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: true, }, - Evaluation::NoMatch { .. } => None, } } -pub(crate) async fn create_approval_requirement_for_command( - policy: &Policy, +/// Return an allow-prefix option when a single plain command needs approval without +/// any matching policy rule. We only surface the prefix opt-in when execpolicy did +/// not already drive the decision (NoMatch) and when the command is a single +/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and +/// when execpolicy feature is enabled. +fn allow_prefix_if_applicable( + commands: &[Vec], + features: &Features, +) -> Option> { + if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { + Some(commands[0].clone()) + } else { + None + } +} + +pub(crate) async fn create_exec_approval_requirement_for_command( + exec_policy: &Arc>, + features: &Features, command: &[String], approval_policy: AskForApproval, sandbox_policy: &SandboxPolicy, sandbox_permissions: SandboxPermissions, -) -> ApprovalRequirement { - if let Some(requirement) = evaluate_with_policy(policy, command, approval_policy) { - return requirement; - } +) -> ExecApprovalRequirement { + let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); + let evaluation = exec_policy.read().await.check_multiple(commands.iter()); - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ApprovalRequirement::NeedsApproval { reason: None } - } else { - ApprovalRequirement::Skip { - bypass_sandbox: false, + match evaluation { + Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), + Evaluation::NoMatch { .. } => { + if requires_initial_appoval( + approval_policy, + sandbox_policy, + command, + sandbox_permissions, + ) { + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: allow_prefix_if_applicable(&commands, features), + } + } else { + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + } + } } } } @@ -195,6 +268,7 @@ mod tests { use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; + use std::sync::Arc; use tempfile::tempdir; #[tokio::test] @@ -209,7 +283,7 @@ mod tests { let commands = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(commands.iter()), + policy.read().await.check_multiple(commands.iter()), Evaluation::NoMatch { .. } )); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); @@ -243,7 +317,7 @@ mod tests { .expect("policy result"); let command = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::Match { .. } )); } @@ -262,13 +336,13 @@ mod tests { .expect("policy result"); let command = [vec!["ls".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::NoMatch { .. } )); } - #[test] - fn evaluates_bash_lc_inner_commands() { + #[tokio::test] + async fn evaluates_bash_lc_inner_commands() { let policy_src = r#" prefix_rule(pattern=["rm"], decision="forbidden") "#; @@ -276,7 +350,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let forbidden_script = vec![ "bash".to_string(), @@ -284,30 +358,37 @@ prefix_rule(pattern=["rm"], decision="forbidden") "rm -rf /tmp".to_string(), ]; - let requirement = - evaluate_with_policy(&policy, &forbidden_script, AskForApproval::OnRequest) - .expect("expected match for forbidden command"); + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &forbidden_script, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await; assert_eq!( requirement, - ApprovalRequirement::Forbidden { + ExecApprovalRequirement::Forbidden { reason: FORBIDDEN_REASON.to_string() } ); } #[tokio::test] - async fn approval_requirement_prefers_execpolicy_match() { + async fn exec_approval_requirement_prefers_execpolicy_match() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let command = vec!["rm".to_string()]; - let requirement = create_approval_requirement_for_command( + let requirement = create_exec_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::OnRequest, &SandboxPolicy::DangerFullAccess, @@ -317,24 +398,26 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::NeedsApproval { - reason: Some(PROMPT_REASON.to_string()) + ExecApprovalRequirement::NeedsApproval { + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, } ); } #[tokio::test] - async fn approval_requirement_respects_approval_policy() { + async fn exec_approval_requirement_respects_approval_policy() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let command = vec!["rm".to_string()]; - let requirement = create_approval_requirement_for_command( + let requirement = create_exec_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::Never, &SandboxPolicy::DangerFullAccess, @@ -344,19 +427,20 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::Forbidden { + ExecApprovalRequirement::Forbidden { reason: PROMPT_REASON.to_string() } ); } #[tokio::test] - async fn approval_requirement_falls_back_to_heuristics() { - let command = vec!["python".to_string()]; + async fn exec_approval_requirement_falls_back_to_heuristics() { + let command = vec!["cargo".to_string(), "build".to_string()]; - let empty_policy = Policy::empty(); - let requirement = create_approval_requirement_for_command( + let empty_policy = Arc::new(RwLock::new(Policy::empty())); + let requirement = create_exec_approval_requirement_for_command( &empty_policy, + &Features::with_defaults(), &command, AskForApproval::UnlessTrusted, &SandboxPolicy::ReadOnly, @@ -366,7 +450,164 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::NeedsApproval { reason: None } + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[tokio::test] + async fn append_allow_prefix_rule_updates_policy_and_file() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(RwLock::new(Policy::empty())); + let prefix = vec!["echo".to_string(), "hello".to_string()]; + + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &prefix) + .await + .expect("update policy"); + + let evaluation = current_policy.read().await.check(&[ + "echo".to_string(), + "hello".to_string(), + "world".to_string(), + ]); + assert!(matches!( + evaluation, + Evaluation::Match { + decision: Decision::Allow, + .. + } + )); + + let contents = fs::read_to_string(default_policy_path(codex_home.path())) + .expect("policy file should have been created"); + assert_eq!( + contents, + r#"prefix_rule(pattern=["echo", "hello"], decision="allow") +"# + ); + } + + #[tokio::test] + async fn append_allow_prefix_rule_rejects_empty_prefix() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(RwLock::new(Policy::empty())); + + let result = + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &[]).await; + + assert!(matches!( + result, + Err(ExecPolicyUpdateError::AppendRule { + source: AmendError::EmptyPrefix, + .. + }) + )); + } + + #[tokio::test] + async fn allow_prefix_is_present_for_single_command_without_policy_match() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let empty_policy = Arc::new(RwLock::new(Policy::empty())); + let requirement = create_exec_approval_requirement_for_command( + &empty_policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &features, + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_omitted_when_policy_prompts() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec!["rm".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_omitted_for_multi_command_scripts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ]; + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } ); } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index d1b7d3144c..cd05d126bf 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::codex::TurnContext; use crate::exec::ExecParams; use crate::exec_env::create_env; -use crate::exec_policy::create_approval_requirement_for_command; +use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; @@ -231,8 +231,10 @@ impl ShellHandler { let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); emitter.begin(event_ctx).await; - let approval_requirement = create_approval_requirement_for_command( + let features = session.features(); + let exec_approval_requirement = create_exec_approval_requirement_for_command( &turn.exec_policy, + &features, &exec_params.command, turn.approval_policy, &turn.sandbox_policy, @@ -247,7 +249,7 @@ impl ShellHandler { env: exec_params.env.clone(), with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), - approval_requirement, + exec_approval_requirement, }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ShellRuntime::new(); diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index de23d510bf..5ac3c63509 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -11,14 +11,14 @@ use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; -use crate::tools::sandboxing::default_approval_requirement; +use crate::tools::sandboxing::default_exec_approval_requirement; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; @@ -54,17 +54,17 @@ impl ToolOrchestrator { // 1) Approval let mut already_approved = false; - let requirement = tool.approval_requirement(req).unwrap_or_else(|| { - default_approval_requirement(approval_policy, &turn_ctx.sandbox_policy) + let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { + default_exec_approval_requirement(approval_policy, &turn_ctx.sandbox_policy) }); match requirement { - ApprovalRequirement::Skip { .. } => { - otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg); + ExecApprovalRequirement::Skip { .. } => { + otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg); } - ApprovalRequirement::Forbidden { reason } => { + ExecApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason)); } - ApprovalRequirement::NeedsApproval { reason } => { + ExecApprovalRequirement::NeedsApproval { reason, .. } => { let mut risk = None; if let Some(metadata) = req.sandbox_retry_data() { @@ -88,13 +88,15 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone()); + otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone()); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => {} } already_approved = true; } @@ -169,13 +171,15 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user); + otel.tool_decision(otel_tn, otel_ci, &decision, otel_user); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 2334f1e712..7ef8d33767 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -127,6 +127,7 @@ impl Approvable for ApplyPatchRuntime { cwd, Some(reason), risk, + None, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 56c72a8278..48dc5b9990 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -9,7 +9,7 @@ use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -32,7 +32,7 @@ pub struct ShellRequest { pub env: std::collections::HashMap, pub with_escalated_permissions: Option, pub justification: Option, - pub approval_requirement: ApprovalRequirement, + pub exec_approval_requirement: ExecApprovalRequirement, } impl ProvidesSandboxRetryData for ShellRequest { @@ -107,22 +107,30 @@ impl Approvable for ShellRuntime { Box::pin(async move { with_cached_approval(&session.services, key, move || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.exec_approval_requirement.allow_prefix().cloned(), + ) .await }) .await }) } - fn approval_requirement(&self, req: &ShellRequest) -> Option { - Some(req.approval_requirement.clone()) + fn exec_approval_requirement(&self, req: &ShellRequest) -> Option { + Some(req.exec_approval_requirement.clone()) } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { if req.with_escalated_permissions.unwrap_or(false) || matches!( - req.approval_requirement, - ApprovalRequirement::Skip { + req.exec_approval_requirement, + ExecApprovalRequirement::Skip { bypass_sandbox: true } ) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0f306e6ff2..45d804d688 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -10,7 +10,7 @@ use crate::exec::ExecExpiration; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -36,7 +36,7 @@ pub struct UnifiedExecRequest { pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, - pub approval_requirement: ApprovalRequirement, + pub exec_approval_requirement: ExecApprovalRequirement, } impl ProvidesSandboxRetryData for UnifiedExecRequest { @@ -66,7 +66,7 @@ impl UnifiedExecRequest { env: HashMap, with_escalated_permissions: Option, justification: Option, - approval_requirement: ApprovalRequirement, + exec_approval_requirement: ExecApprovalRequirement, ) -> Self { Self { command, @@ -74,7 +74,7 @@ impl UnifiedExecRequest { env, with_escalated_permissions, justification, - approval_requirement, + exec_approval_requirement, } } } @@ -125,22 +125,33 @@ impl Approvable for UnifiedExecRuntime<'_> { Box::pin(async move { with_cached_approval(&session.services, key, || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.exec_approval_requirement.allow_prefix().cloned(), + ) .await }) .await }) } - fn approval_requirement(&self, req: &UnifiedExecRequest) -> Option { - Some(req.approval_requirement.clone()) + fn exec_approval_requirement( + &self, + req: &UnifiedExecRequest, + ) -> Option { + Some(req.exec_approval_requirement.clone()) } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { if req.with_escalated_permissions.unwrap_or(false) || matches!( - req.approval_requirement, - ApprovalRequirement::Skip { + req.exec_approval_requirement, + ExecApprovalRequirement::Skip { bypass_sandbox: true } ) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index df10db952e..793c162936 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -88,26 +88,42 @@ pub(crate) struct ApprovalCtx<'a> { // Specifies what tool orchestrator should do with a given tool call. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ApprovalRequirement { +pub(crate) enum ExecApprovalRequirement { /// No approval required for this tool call. Skip { /// The first attempt should skip sandboxing (e.g., when explicitly /// greenlit by policy). bypass_sandbox: bool, }, - /// Approval required for this tool call - NeedsApproval { reason: Option }, - /// Execution forbidden for this tool call + /// Approval required for this tool call. + NeedsApproval { + reason: Option, + /// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands + allow_prefix: Option>, + }, + /// Execution forbidden for this tool call. Forbidden { reason: String }, } +impl ExecApprovalRequirement { + pub fn allow_prefix(&self) -> Option<&Vec> { + match self { + Self::NeedsApproval { + allow_prefix: Some(prefix), + .. + } => Some(prefix), + _ => None, + } + } +} + /// - Never, OnFailure: do not ask /// - OnRequest: ask unless sandbox policy is DangerFullAccess /// - UnlessTrusted: always ask -pub(crate) fn default_approval_requirement( +pub(crate) fn default_exec_approval_requirement( policy: AskForApproval, sandbox_policy: &SandboxPolicy, -) -> ApprovalRequirement { +) -> ExecApprovalRequirement { let needs_approval = match policy { AskForApproval::Never | AskForApproval::OnFailure => false, AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), @@ -115,9 +131,12 @@ pub(crate) fn default_approval_requirement( }; if needs_approval { - ApprovalRequirement::NeedsApproval { reason: None } + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } } else { - ApprovalRequirement::Skip { + ExecApprovalRequirement::Skip { bypass_sandbox: false, } } @@ -149,10 +168,9 @@ pub(crate) trait Approvable { matches!(policy, AskForApproval::Never) } - /// Override the default approval requirement. Return `Some(_)` to specify - /// a custom requirement, or `None` to fall back to - /// policy-based default. - fn approval_requirement(&self, _req: &Req) -> Option { + /// Return `Some(_)` to specify a custom exec approval requirement, or `None` + /// to fall back to policy-based default. + fn exec_approval_requirement(&self, _req: &Req) -> Option { None } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index d37ad4d3fc..51e5626073 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -15,7 +15,7 @@ use crate::codex::TurnContext; use crate::exec::ExecToolCallOutput; use crate::exec::StreamOutput; use crate::exec_env::create_env; -use crate::exec_policy::create_approval_requirement_for_command; +use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; @@ -556,10 +556,12 @@ impl UnifiedExecSessionManager { context: &UnifiedExecContext, ) -> Result { let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); + let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); - let approval_requirement = create_approval_requirement_for_command( + let exec_approval_requirement = create_exec_approval_requirement_for_command( &context.turn.exec_policy, + &features, command, context.turn.approval_policy, &context.turn.sandbox_policy, @@ -572,7 +574,7 @@ impl UnifiedExecSessionManager { env, with_escalated_permissions, justification, - approval_requirement, + exec_approval_requirement, ); let tool_ctx = ToolCtx { session: context.session.as_ref(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index caa488a88f..8ac31fb378 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1523,7 +1523,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::ExecApproval { id: "0".into(), - decision: *decision, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; @@ -1544,7 +1544,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::PatchApproval { id: "0".into(), - decision: *decision, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; @@ -1557,3 +1557,148 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::ReadOnly; + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = approval_policy; + config.sandbox_policy = sandbox_policy_for_config; + }); + let test = builder.build(&server).await?; + let allow_prefix_path = test.cwd.path().join("allow-prefix.txt"); + let _ = fs::remove_file(&allow_prefix_path); + + let call_id_first = "allow-prefix-first"; + let (first_event, expected_command) = ActionKind::RunCommand { + command: "touch allow-prefix.txt", + } + .prepare(&test, &server, call_id_first, false) + .await?; + let expected_command = + expected_command.expect("allow prefix scenario should produce a shell command"); + let expected_allow_prefix = vec!["touch".to_string(), "allow-prefix.txt".to_string()]; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-allow-prefix-1"), + first_event, + ev_completed("resp-allow-prefix-1"), + ]), + ) + .await; + let first_results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-allow-prefix-1", "done"), + ev_completed("resp-allow-prefix-2"), + ]), + ) + .await; + + submit_turn( + &test, + "allow-prefix-first", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let approval = expect_exec_approval(&test, expected_command.as_str()).await; + assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone())); + + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::ApprovedAllowPrefix { + allow_prefix: expected_allow_prefix.clone(), + }, + }) + .await?; + wait_for_completion(&test).await; + + let policy_path = test.home.path().join("policy").join("default.codexpolicy"); + let policy_contents = fs::read_to_string(&policy_path)?; + assert!( + policy_contents + .contains(r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#), + "unexpected policy contents: {policy_contents}" + ); + + let first_output = parse_result( + &first_results + .single_request() + .function_call_output(call_id_first), + ); + assert_eq!(first_output.exit_code.unwrap_or(0), 0); + assert!( + first_output.stdout.is_empty(), + "unexpected stdout: {}", + first_output.stdout + ); + assert_eq!( + fs::read_to_string(&allow_prefix_path)?, + "", + "unexpected file contents after first run" + ); + + let call_id_second = "allow-prefix-second"; + let (second_event, second_command) = ActionKind::RunCommand { + command: "touch allow-prefix.txt", + } + .prepare(&test, &server, call_id_second, false) + .await?; + assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-allow-prefix-3"), + second_event, + ev_completed("resp-allow-prefix-3"), + ]), + ) + .await; + let second_results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-allow-prefix-2", "done"), + ev_completed("resp-allow-prefix-4"), + ]), + ) + .await; + + submit_turn( + &test, + "allow-prefix-second", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + wait_for_completion_without_approval(&test).await; + + let second_output = parse_result( + &second_results + .single_request() + .function_call_output(call_id_second), + ); + assert_eq!(second_output.exit_code.unwrap_or(0), 0); + assert!( + second_output.stdout.is_empty(), + "unexpected stdout: {}", + second_output.stdout + ); + assert_eq!( + fs::read_to_string(&allow_prefix_path)?, + "", + "unexpected file contents after second run" + ); + + Ok(()) +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 55808f17ca..cbee0fe0e7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,6 +180,7 @@ async fn run_codex_tool_session_inner( call_id, reason: _, risk, + allow_prefix: _, parsed_cmd, }) => { handle_exec_approval_request( diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index c300f3fb82..d3536cd8db 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -352,7 +352,7 @@ impl OtelEventManager { &self, tool_name: &str, call_id: &str, - decision: ReviewDecision, + decision: &ReviewDecision, source: ToolDecisionSource, ) { tracing::event!( @@ -369,7 +369,7 @@ impl OtelEventManager { slug = %self.metadata.slug, tool_name = %tool_name, call_id = %call_id, - decision = %decision.to_string().to_lowercase(), + decision = %decision.clone().to_string().to_lowercase(), source = %source.to_string(), ); } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 9ef3a9e368..54a9efca9f 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -51,6 +51,10 @@ pub struct ExecApprovalRequestEvent { /// Optional model-provided risk assessment describing the blocked command. #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, + /// Prefix rule that can be added to the user's execpolicy to allow future runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "Array")] + pub allow_prefix: Option>, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 99d2ec70d3..ef06c2e1dc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1649,14 +1649,16 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive( - Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, -)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, + /// User has approved this command and wants to add the command prefix to + /// the execpolicy allow list so future matching commands are permitted. + ApprovedAllowPrefix { allow_prefix: Vec }, + /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the /// remainder of the session. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index c6006a9ae7..3defdbf9e3 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -43,6 +43,7 @@ pub(crate) enum ApprovalRequest { command: Vec, reason: Option, risk: Option, + allow_prefix: Option>, }, ApplyPatch { id: String, @@ -104,8 +105,8 @@ impl ApprovalOverlay { header: Box, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { - ApprovalVariant::Exec { .. } => ( - exec_options(), + ApprovalVariant::Exec { allow_prefix, .. } => ( + exec_options(allow_prefix.clone()), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -160,12 +161,12 @@ impl ApprovalOverlay { return; }; if let Some(variant) = self.current_variant.as_ref() { - match (&variant, &option.decision) { - (ApprovalVariant::Exec { id, command }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, *decision); + match (variant, &option.decision) { + (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { + self.handle_exec_decision(id, command, decision.clone()); } (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { - self.handle_patch_decision(id, *decision); + self.handle_patch_decision(id, decision.clone()); } ( ApprovalVariant::McpElicitation { @@ -185,7 +186,7 @@ impl ApprovalOverlay { } fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), @@ -273,7 +274,7 @@ impl BottomPaneView for ApprovalOverlay { && let Some(variant) = self.current_variant.as_ref() { match &variant { - ApprovalVariant::Exec { id, command } => { + ApprovalVariant::Exec { id, command, .. } => { self.handle_exec_decision(id, command, ReviewDecision::Abort); } ApprovalVariant::ApplyPatch { id, .. } => { @@ -336,6 +337,7 @@ impl From for ApprovalRequestState { command, reason, risk, + allow_prefix, } => { let reason = reason.filter(|item| !item.is_empty()); let has_reason = reason.is_some(); @@ -355,7 +357,11 @@ impl From for ApprovalRequestState { } header.extend(full_cmd_lines); Self { - variant: ApprovalVariant::Exec { id, command }, + variant: ApprovalVariant::Exec { + id, + command, + allow_prefix, + }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } } @@ -431,6 +437,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, + allow_prefix: Option>, }, ApplyPatch { id: String, @@ -463,7 +470,7 @@ impl ApprovalOption { } } -fn exec_options() -> Vec { +fn exec_options(allow_prefix: Option>) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), @@ -472,18 +479,28 @@ fn exec_options() -> Vec { additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, ApprovalOption { - label: "Yes, and don't ask again for this command".to_string(), + label: "Yes, and don't ask again this session".to_string(), decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }, - ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, ] + .into_iter() + .chain(allow_prefix.map(|prefix| ApprovalOption { + label: "Yes, and don't ask again for commands with this prefix".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix { + allow_prefix: prefix, + }), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + })) + .chain([ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }]) + .collect() } fn patch_options() -> Vec { @@ -539,6 +556,7 @@ mod tests { command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), risk: None, + allow_prefix: None, } } @@ -571,6 +589,40 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + #[test] + fn exec_prefix_option_emits_allow_prefix() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + risk: None, + allow_prefix: Some(vec!["echo".to_string()]), + }, + tx, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + assert_eq!( + decision, + ReviewDecision::ApprovedAllowPrefix { + allow_prefix: vec!["echo".to_string()] + } + ); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with allow prefix" + ); + } + #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -581,6 +633,7 @@ mod tests { command, reason: None, risk: None, + allow_prefix: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a0425c92d7..844442c24c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,6 +570,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, risk: None, + allow_prefix: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c257f7c1bd..4aa397ad90 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1074,6 +1074,7 @@ impl ChatWidget { command: ev.command, reason: ev.reason, risk: ev.risk, + allow_prefix: ev.allow_prefix, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index b84588e337..eaf0fed3a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -10,7 +10,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again this session (a) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 543d367d23..ce2277ce62 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -7,7 +7,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again this session (a) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f986a927e5..ff70d7d492 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -15,7 +15,7 @@ Buffer { " $ echo hello world ", " ", "› 1. Yes, proceed (y) ", - " 2. Yes, and don't ask again for this command (a) ", + " 2. Yes, and don't ask again this session (a) ", " 3. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", @@ -30,8 +30,8 @@ Buffer { x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 44, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index f98c807878..5ce388c87b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1548 expression: terminal.backend() --- " " @@ -13,7 +12,8 @@ expression: terminal.backend() " $ echo 'hello world' " " " "› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for this command (a) " -" 3. No, and tell Codex what to do differently (esc) " +" 2. Yes, and don't ask again this session (a) " +" 3. Yes, and don't ask again for commands with this prefix (p) " +" 4. No, and tell Codex what to do differently (esc) " " " " Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 419dab2c87..2b75f960bd 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -688,6 +688,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -732,6 +733,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -782,6 +784,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1990,6 +1993,7 @@ fn approval_modal_exec_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2036,6 +2040,7 @@ fn approval_modal_exec_without_reason_snapshot() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2249,6 +2254,7 @@ fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index de8c06488e..90a54268cb 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -409,6 +409,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedAllowPrefix { .. } => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and added its prefix to your allow list".bold(), + ], + ) + } ApprovedForSession => { let snippet = Span::from(exec_snippet(&command)).dim(); ( diff --git a/docs/config.md b/docs/config.md index 0f0761a301..6aa1bde8c5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -603,7 +603,7 @@ metadata above): - `codex.tool_decision` - `tool_name` - `call_id` - - `decision` (`approved`, `approved_for_session`, `denied`, or `abort`) + - `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`) - `source` (`config` or `user`) - `codex.tool_result` - `tool_name` From 3d35cb4619a5aca7d7d4fe84ae5422ffb0e92651 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:39:48 -0500 Subject: [PATCH 024/159] Refactor execpolicy fallback evaluation (#7544) ## Refactor of the `execpolicy` crate To illustrate why we need this refactor, consider an agent attempting to run `apple | rm -rf ./`. Suppose `apple` is allowed by `execpolicy`. Before this PR, `execpolicy` would consider `apple` and `pear` and only render one rule match: `Allow`. We would skip any heuristics checks on `rm -rf ./` and immediately approve `apple | rm -rf ./` to run. To fix this, we now thread a `fallback` evaluation function into `execpolicy` that runs when no `execpolicy` rules match a given command. In our example, we would run `fallback` on `rm -rf ./` and prevent `apple | rm -rf ./` from being run without approval. --- .../app-server/src/bespoke_event_handling.rs | 2 +- codex-rs/cli/tests/execpolicy.rs | 18 +- codex-rs/core/src/apply_patch.rs | 2 +- codex-rs/core/src/codex.rs | 33 +- codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/exec_policy.rs | 289 +++++++++++++----- codex-rs/core/src/tools/orchestrator.rs | 4 +- codex-rs/core/src/tools/runtimes/shell.rs | 4 +- .../core/src/tools/runtimes/unified_exec.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 12 +- codex-rs/core/tests/suite/approvals.rs | 17 +- codex-rs/execpolicy/README.md | 32 +- codex-rs/execpolicy/src/execpolicycheck.rs | 28 +- codex-rs/execpolicy/src/main.rs | 8 +- codex-rs/execpolicy/src/policy.rs | 96 +++--- codex-rs/execpolicy/src/rule.rs | 5 + codex-rs/execpolicy/tests/basic.rs | 94 ++++-- codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/protocol/src/approvals.rs | 34 ++- codex-rs/protocol/src/protocol.rs | 9 +- .../tui/src/bottom_pane/approval_overlay.rs | 42 +-- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 24 +- codex-rs/tui/src/history_cell.rs | 4 +- docs/config.md | 2 +- docs/execpolicy.md | 24 ++ 27 files changed, 538 insertions(+), 257 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6d03a9f5db..8e39580fa8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -179,7 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, - allow_prefix: _allow_prefix, + proposed_execpolicy_amendment: _, parsed_cmd, }) => match api_version { ApiVersion::V1 => { diff --git a/codex-rs/cli/tests/execpolicy.rs b/codex-rs/cli/tests/execpolicy.rs index c6bca85bc6..4610b95874 100644 --- a/codex-rs/cli/tests/execpolicy.rs +++ b/codex-rs/cli/tests/execpolicy.rs @@ -40,17 +40,15 @@ prefix_rule( assert_eq!( result, json!({ - "match": { - "decision": "forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["git", "push"], - "decision": "forbidden" - } + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden" } - ] - } + } + ] }) ); diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 5cf0ecee70..67433303e5 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -71,7 +71,7 @@ pub(crate) async fn apply_patch( .await; match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7e0a1d685d..34cde906ec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -25,6 +25,7 @@ use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ConversationId; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; @@ -871,13 +872,11 @@ impl Session { .await } - /// Adds a prefix rule to the exec policy - /// - /// This mutates the in-memory execpolicy so the current conversation can use the new - /// prefix and persists the change in default.execpolicy so new conversations will also allow the new prefix. - pub(crate) async fn persist_command_allow_prefix( + /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future + /// commands can use the newly approved prefix. + pub(crate) async fn persist_execpolicy_amendment( &self, - prefix: &[String], + amendment: &ExecPolicyAmendment, ) -> Result<(), ExecPolicyUpdateError> { let features = self.features.clone(); let (codex_home, current_policy) = { @@ -897,10 +896,10 @@ impl Session { return Err(ExecPolicyUpdateError::FeatureDisabled); } - crate::exec_policy::append_allow_prefix_rule_and_update( + crate::exec_policy::append_execpolicy_amendment_and_update( &codex_home, ¤t_policy, - prefix, + &amendment.command, ) .await?; @@ -921,7 +920,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: Option, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -949,7 +948,7 @@ impl Session { cwd, reason, risk, - allow_prefix, + proposed_execpolicy_amendment, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -1672,13 +1671,17 @@ mod handlers { } } - /// Propagate a user's exec approval decision to the session - /// Also optionally whitelists command in execpolicy + /// Propagate a user's exec approval decision to the session. + /// Also optionally applies an execpolicy amendment. pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { - if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision - && let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await + if let ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } = &decision + && let Err(err) = sess + .persist_execpolicy_amendment(proposed_execpolicy_amendment) + .await { - let message = format!("Failed to update execpolicy allow list: {err}"); + let message = format!("Failed to apply execpolicy amendment: {err}"); tracing::warn!("{message}"); let warning = EventMsg::Warning(WarningEvent { message }); sess.send_event_raw(Event { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1606855669..670225ead0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -281,7 +281,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, - event.allow_prefix, + event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 7c96deea90..1bbe60ff11 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -10,7 +10,9 @@ use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; @@ -25,6 +27,8 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; +const PROMPT_CONFLICT_REASON: &str = + "execpolicy requires approval for this command, but AskForApproval is set to Never"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; @@ -112,7 +116,7 @@ pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) } -pub(crate) async fn append_allow_prefix_rule_and_update( +pub(crate) async fn append_execpolicy_amendment_and_update( codex_home: &Path, current_policy: &Arc>, prefix: &[String], @@ -139,45 +143,54 @@ pub(crate) async fn append_allow_prefix_rule_and_update( Ok(()) } -fn requirement_from_decision( - decision: Decision, - approval_policy: AskForApproval, -) -> ExecApprovalRequirement { - match decision { - Decision::Forbidden => ExecApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }, - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - ExecApprovalRequirement::Forbidden { reason } - } else { - ExecApprovalRequirement::NeedsApproval { - reason: Some(reason), - allow_prefix: None, +/// Returns a proposed execpolicy amendment only when heuristics caused +/// the prompt decision, so we can offer to apply that amendment for future runs. +/// +/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit +/// execpolicy rule also prompts, we return `None` because applying the amendment would not +/// skip that policy requirement. +/// +/// Examples: +/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`. +/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`. +/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt +/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`. +/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above, +/// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"]. +fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option { + if evaluation.decision != Decision::Prompt { + return None; + } + + let mut first_prompt_from_heuristics: Option> = None; + for rule_match in &evaluation.matched_rules { + match rule_match { + RuleMatch::HeuristicsRuleMatch { command, decision } => { + if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { + first_prompt_from_heuristics = Some(command.clone()); } } + _ if rule_match.decision() == Decision::Prompt => { + return None; + } + _ => {} } - Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: true, - }, } + + first_prompt_from_heuristics.map(ExecPolicyAmendment::from) } -/// Return an allow-prefix option when a single plain command needs approval without -/// any matching policy rule. We only surface the prefix opt-in when execpolicy did -/// not already drive the decision (NoMatch) and when the command is a single -/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and -/// when execpolicy feature is enabled. -fn allow_prefix_if_applicable( - commands: &[Vec], - features: &Features, -) -> Option> { - if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { - Some(commands[0].clone()) - } else { - None - } +/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. +fn derive_prompt_reason(evaluation: &Evaluation) -> Option { + evaluation.matched_rules.iter().find_map(|rule_match| { + if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Prompt + { + Some(PROMPT_REASON.to_string()) + } else { + None + } + }) } pub(crate) async fn create_exec_approval_requirement_for_command( @@ -189,27 +202,43 @@ pub(crate) async fn create_exec_approval_requirement_for_command( sandbox_permissions: SandboxPermissions, ) -> ExecApprovalRequirement { let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = exec_policy.read().await.check_multiple(commands.iter()); + let heuristics_fallback = |cmd: &[String]| { + if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) { + Decision::Prompt + } else { + Decision::Allow + } + }; + let policy = exec_policy.read().await; + let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); + let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { + !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Allow + }); - match evaluation { - Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), - Evaluation::NoMatch { .. } => { - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ExecApprovalRequirement::NeedsApproval { - reason: None, - allow_prefix: allow_prefix_if_applicable(&commands, features), + match evaluation.decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + if matches!(approval_policy, AskForApproval::Never) { + ExecApprovalRequirement::Forbidden { + reason: PROMPT_CONFLICT_REASON.to_string(), } } else { - ExecApprovalRequirement::Skip { - bypass_sandbox: false, + ExecApprovalRequirement::NeedsApproval { + reason: derive_prompt_reason(&evaluation), + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + proposed_execpolicy_amendment(&evaluation) + } else { + None + }, } } } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: has_policy_allow, + }, } } @@ -282,10 +311,19 @@ mod tests { .expect("policy result"); let commands = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(commands.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["rm".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(commands.iter(), &|_| Decision::Allow) + ); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); } @@ -316,10 +354,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::Match { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -335,10 +382,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["ls".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["ls".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -400,7 +456,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: Some(PROMPT_REASON.to_string()), - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } @@ -428,7 +484,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, ExecApprovalRequirement::Forbidden { - reason: PROMPT_REASON.to_string() + reason: PROMPT_CONFLICT_REASON.to_string() } ); } @@ -452,29 +508,61 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: Some(command) + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) } ); } #[tokio::test] - async fn append_allow_prefix_rule_updates_policy_and_file() { + async fn heuristics_apply_when_other_commands_match_policy() { + let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "apple | orange".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "orange".to_string() + ])) + } + ); + } + + #[tokio::test] + async fn append_execpolicy_amendment_updates_policy_and_file() { let codex_home = tempdir().expect("create temp dir"); let current_policy = Arc::new(RwLock::new(Policy::empty())); let prefix = vec!["echo".to_string(), "hello".to_string()]; - append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &prefix) + append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &prefix) .await .expect("update policy"); - let evaluation = current_policy.read().await.check(&[ - "echo".to_string(), - "hello".to_string(), - "world".to_string(), - ]); + let evaluation = current_policy.read().await.check( + &["echo".to_string(), "hello".to_string(), "world".to_string()], + &|_| Decision::Allow, + ); assert!(matches!( evaluation, - Evaluation::Match { + Evaluation { decision: Decision::Allow, .. } @@ -490,12 +578,12 @@ prefix_rule(pattern=["rm"], decision="forbidden") } #[tokio::test] - async fn append_allow_prefix_rule_rejects_empty_prefix() { + async fn append_execpolicy_amendment_rejects_empty_prefix() { let codex_home = tempdir().expect("create temp dir"); let current_policy = Arc::new(RwLock::new(Policy::empty())); let result = - append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &[]).await; + append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &[]).await; assert!(matches!( result, @@ -507,7 +595,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") } #[tokio::test] - async fn allow_prefix_is_present_for_single_command_without_policy_match() { + async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { let command = vec!["cargo".to_string(), "build".to_string()]; let empty_policy = Arc::new(RwLock::new(Policy::empty())); @@ -525,13 +613,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: Some(command) + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) } ); } #[tokio::test] - async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() { + async fn proposed_execpolicy_amendment_is_disabled_when_execpolicy_feature_disabled() { let command = vec!["cargo".to_string(), "build".to_string()]; let mut features = Features::with_defaults(); @@ -551,13 +639,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } #[tokio::test] - async fn allow_prefix_is_omitted_when_policy_prompts() { + async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser @@ -580,13 +668,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: Some(PROMPT_REASON.to_string()), - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } #[tokio::test] - async fn allow_prefix_is_omitted_for_multi_command_scripts() { + async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { let command = vec![ "bash".to_string(), "-lc".to_string(), @@ -606,7 +694,44 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "build".to_string() + ])), + } + ); + } + + #[tokio::test] + async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { + let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cat && apple".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "apple".to_string() + ])), } ); } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 5ac3c63509..4c34658fcd 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -95,7 +95,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} } already_approved = true; @@ -178,7 +178,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 48dc5b9990..2af095ee92 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -114,7 +114,9 @@ impl Approvable for ShellRuntime { cwd, reason, risk, - req.exec_approval_requirement.allow_prefix().cloned(), + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), ) .await }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 45d804d688..4c1cbb83ec 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -132,7 +132,9 @@ impl Approvable for UnifiedExecRuntime<'_> { cwd, reason, risk, - req.exec_approval_requirement.allow_prefix().cloned(), + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), ) .await }) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 793c162936..94c81043cc 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -13,6 +13,7 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use std::collections::HashMap; @@ -98,18 +99,19 @@ pub(crate) enum ExecApprovalRequirement { /// Approval required for this tool call. NeedsApproval { reason: Option, - /// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands - allow_prefix: Option>, + /// Proposed execpolicy amendment to skip future approvals for similar commands + /// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined. + proposed_execpolicy_amendment: Option, }, /// Execution forbidden for this tool call. Forbidden { reason: String }, } impl ExecApprovalRequirement { - pub fn allow_prefix(&self) -> Option<&Vec> { + pub fn proposed_execpolicy_amendment(&self) -> Option<&ExecPolicyAmendment> { match self { Self::NeedsApproval { - allow_prefix: Some(prefix), + proposed_execpolicy_amendment: Some(prefix), .. } => Some(prefix), _ => None, @@ -133,7 +135,7 @@ pub(crate) fn default_exec_approval_requirement( if needs_approval { ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } else { ExecApprovalRequirement::Skip { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 8ac31fb378..4570e6a5b9 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -6,6 +6,7 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; @@ -1560,7 +1561,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> { +async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::ReadOnly; @@ -1580,8 +1581,9 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re .prepare(&test, &server, call_id_first, false) .await?; let expected_command = - expected_command.expect("allow prefix scenario should produce a shell command"); - let expected_allow_prefix = vec!["touch".to_string(), "allow-prefix.txt".to_string()]; + expected_command.expect("execpolicy amendment scenario should produce a shell command"); + let expected_execpolicy_amendment = + ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]); let _ = mount_sse_once( &server, @@ -1610,13 +1612,16 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re .await?; let approval = expect_exec_approval(&test, expected_command.as_str()).await; - assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone())); + assert_eq!( + approval.proposed_execpolicy_amendment, + Some(expected_execpolicy_amendment.clone()) + ); test.codex .submit(Op::ExecApproval { id: "0".into(), - decision: ReviewDecision::ApprovedAllowPrefix { - allow_prefix: expected_allow_prefix.clone(), + decision: ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(), }, }) .await?; diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index 9fd9c63306..1ddb67252f 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -30,32 +30,24 @@ codex execpolicy check --policy path/to/policy.codexpolicy git status cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status ``` - Example outcomes: - - Match: `{"match": { ... "decision": "allow" ... }}` - - No match: `{"noMatch": {}}` + - Match: `{"matchedRules":[{...}],"decision":"allow"}` + - No match: `{"matchedRules":[]}` -## Response shapes -- Match: +## Response shape ```json { - "match": { - "decision": "allow|prompt|forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["", "..."], - "decision": "allow|prompt|forbidden" - } + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["", "..."], + "decision": "allow|prompt|forbidden" } - ] - } + } + ], + "decision": "allow|prompt|forbidden" } ``` - -- No match: -```json -{"noMatch": {}} -``` - +- When no rules match, `matchedRules` is an empty array and `decision` is omitted. - `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched. - The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`). diff --git a/codex-rs/execpolicy/src/execpolicycheck.rs b/codex-rs/execpolicy/src/execpolicycheck.rs index 0b5e0dcafc..939ed8590b 100644 --- a/codex-rs/execpolicy/src/execpolicycheck.rs +++ b/codex-rs/execpolicy/src/execpolicycheck.rs @@ -4,10 +4,12 @@ use std::path::PathBuf; use anyhow::Context; use anyhow::Result; use clap::Parser; +use serde::Serialize; -use crate::Evaluation; +use crate::Decision; use crate::Policy; use crate::PolicyParser; +use crate::RuleMatch; /// Arguments for evaluating a command against one or more execpolicy files. #[derive(Debug, Parser, Clone)] @@ -34,20 +36,25 @@ impl ExecPolicyCheckCommand { /// Load the policies for this command, evaluate the command, and render JSON output. pub fn run(&self) -> Result<()> { let policy = load_policies(&self.policies)?; - let evaluation = policy.check(&self.command); + let matched_rules = policy.matches_for_command(&self.command, None); - let json = format_evaluation_json(&evaluation, self.pretty)?; + let json = format_matches_json(&matched_rules, self.pretty)?; println!("{json}"); Ok(()) } } -pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result { +pub fn format_matches_json(matched_rules: &[RuleMatch], pretty: bool) -> Result { + let output = ExecPolicyCheckOutput { + matched_rules, + decision: matched_rules.iter().map(RuleMatch::decision).max(), + }; + if pretty { - serde_json::to_string_pretty(evaluation).map_err(Into::into) + serde_json::to_string_pretty(&output).map_err(Into::into) } else { - serde_json::to_string(evaluation).map_err(Into::into) + serde_json::to_string(&output).map_err(Into::into) } } @@ -65,3 +72,12 @@ pub fn load_policies(policy_paths: &[PathBuf]) -> Result { Ok(parser.build()) } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecPolicyCheckOutput<'a> { + #[serde(rename = "matchedRules")] + matched_rules: &'a [RuleMatch], + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, +} diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs index e1373b6d16..d3b34a3307 100644 --- a/codex-rs/execpolicy/src/main.rs +++ b/codex-rs/execpolicy/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use codex_execpolicy::ExecPolicyCheckCommand; +use codex_execpolicy::execpolicycheck::ExecPolicyCheckCommand; /// CLI for evaluating exec policies #[derive(Parser)] @@ -13,10 +13,6 @@ enum Cli { fn main() -> Result<()> { let cli = Cli::parse(); match cli { - Cli::Check(cmd) => cmd_check(cmd), + Cli::Check(cmd) => cmd.run(), } } - -fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> { - cmd.run() -} diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 10858c9fad..991e904ae9 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -11,6 +11,8 @@ use serde::Deserialize; use serde::Serialize; use std::sync::Arc; +type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>; + #[derive(Clone, Debug)] pub struct Policy { rules_by_program: MultiMap, @@ -50,62 +52,84 @@ impl Policy { Ok(()) } - pub fn check(&self, cmd: &[String]) -> Evaluation { - let rules = match cmd.first() { - Some(first) => match self.rules_by_program.get_vec(first) { - Some(rules) => rules, - None => return Evaluation::NoMatch {}, - }, - None => return Evaluation::NoMatch {}, - }; - - let matched_rules: Vec = - rules.iter().filter_map(|rule| rule.matches(cmd)).collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, - } + pub fn check(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation + where + F: Fn(&[String]) -> Decision, + { + let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback)); + Evaluation::from_matches(matched_rules) } - pub fn check_multiple(&self, commands: Commands) -> Evaluation + pub fn check_multiple( + &self, + commands: Commands, + heuristics_fallback: &F, + ) -> Evaluation where Commands: IntoIterator, Commands::Item: AsRef<[String]>, + F: Fn(&[String]) -> Decision, { let matched_rules: Vec = commands .into_iter() - .flat_map(|command| match self.check(command.as_ref()) { - Evaluation::Match { matched_rules, .. } => matched_rules, - Evaluation::NoMatch { .. } => Vec::new(), + .flat_map(|command| { + self.matches_for_command(command.as_ref(), Some(heuristics_fallback)) }) .collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, + Evaluation::from_matches(matched_rules) + } + + pub fn matches_for_command( + &self, + cmd: &[String], + heuristics_fallback: HeuristicsFallback<'_>, + ) -> Vec { + let mut matched_rules: Vec = match cmd.first() { + Some(first) => self + .rules_by_program + .get_vec(first) + .map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect()) + .unwrap_or_default(), + None => Vec::new(), + }; + + if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) { + matched_rules.push(RuleMatch::HeuristicsRuleMatch { + command: cmd.to_vec(), + decision: heuristics_fallback(cmd), + }); } + + matched_rules } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub enum Evaluation { - NoMatch {}, - Match { - decision: Decision, - #[serde(rename = "matchedRules")] - matched_rules: Vec, - }, +pub struct Evaluation { + pub decision: Decision, + #[serde(rename = "matchedRules")] + pub matched_rules: Vec, } impl Evaluation { pub fn is_match(&self) -> bool { - matches!(self, Self::Match { .. }) + self.matched_rules + .iter() + .any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })) + } + + fn from_matches(matched_rules: Vec) -> Self { + let decision = matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Allow); + + Self { + decision, + matched_rules, + } } } diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs index 20e23fe6a2..cd0756bbb3 100644 --- a/codex-rs/execpolicy/src/rule.rs +++ b/codex-rs/execpolicy/src/rule.rs @@ -64,12 +64,17 @@ pub enum RuleMatch { matched_prefix: Vec, decision: Decision, }, + HeuristicsRuleMatch { + command: Vec, + decision: Decision, + }, } impl RuleMatch { pub fn decision(&self) -> Decision { match self { Self::PrefixRuleMatch { decision, .. } => *decision, + Self::HeuristicsRuleMatch { decision, .. } => *decision, } } } diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index 9a7ec58b1e..3ea33604ae 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -19,6 +19,14 @@ fn tokens(cmd: &[&str]) -> Vec { cmd.iter().map(std::string::ToString::to_string).collect() } +fn allow_all(_: &[String]) -> Decision { + Decision::Allow +} + +fn prompt_all(_: &[String]) -> Decision { + Decision::Prompt +} + #[derive(Clone, Debug, Eq, PartialEq)] enum RuleSnapshot { Prefix(PrefixRule), @@ -49,9 +57,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); let cmd = tokens(&["git", "status"]); - let evaluation = policy.check(&cmd); + let evaluation = policy.check(&cmd, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -80,9 +88,9 @@ fn add_prefix_rule_extends_policy() -> Result<()> { rules ); - let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"])); + let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["ls", "-l"]), @@ -146,9 +154,9 @@ prefix_rule( git_rules ); - let status_eval = policy.check(&tokens(&["git", "status"])); + let status_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git"]), @@ -158,9 +166,9 @@ prefix_rule( status_eval ); - let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -217,9 +225,9 @@ prefix_rule( sh_rules ); - let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"])); + let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["bash", "-c"]), @@ -229,9 +237,9 @@ prefix_rule( bash_eval ); - let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"])); + let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["sh", "-l"]), @@ -273,9 +281,9 @@ prefix_rule( rules ); - let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"])); + let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]), @@ -285,9 +293,12 @@ prefix_rule( npm_i ); - let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"])); + let npm_install = policy.check( + &tokens(&["npm", "install", "--no-save", "leftpad"]), + &allow_all, + ); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "install", "--no-save"]), @@ -314,9 +325,9 @@ prefix_rule( let mut parser = PolicyParser::new(); parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let match_eval = policy.check(&tokens(&["git", "status"])); + let match_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -326,13 +337,20 @@ prefix_rule( match_eval ); - let no_match_eval = policy.check(&tokens(&[ - "git", - "--config", - "color.status=always", - "status", - ])); - assert_eq!(Evaluation::NoMatch {}, no_match_eval); + let no_match_eval = policy.check( + &tokens(&["git", "--config", "color.status=always", "status"]), + &allow_all, + ); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: tokens(&["git", "--config", "color.status=always", "status",]), + decision: Decision::Allow, + }], + }, + no_match_eval + ); Ok(()) } @@ -352,9 +370,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -393,9 +411,9 @@ prefix_rule( tokens(&["git", "commit", "-m", "hi"]), ]; - let evaluation = policy.check_multiple(&commands); + let evaluation = policy.check_multiple(&commands, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -416,3 +434,21 @@ prefix_rule( ); Ok(()) } + +#[test] +fn heuristics_match_is_returned_when_no_policy_matches() { + let policy = Policy::empty(); + let command = tokens(&["python"]); + + let evaluation = policy.check(&command, &prompt_all); + assert_eq!( + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + }], + }, + evaluation + ); +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index cbee0fe0e7..aa895d8dd3 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,7 +180,7 @@ async fn run_codex_tool_session_inner( call_id, reason: _, risk, - allow_prefix: _, + proposed_execpolicy_amendment: _, parsed_cmd, }) => { handle_exec_approval_request( diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 54a9efca9f..c892b6ec99 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -17,6 +17,34 @@ pub enum SandboxRiskLevel { High, } +/// Proposed execpolicy change to allow commands starting with this prefix. +/// +/// The `command` tokens form the prefix that would be added as an execpolicy +/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for +/// commands that start with this token sequence. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn new(command: Vec) -> Self { + Self { command } + } + + pub fn command(&self) -> &[String] { + &self.command + } +} + +impl From> for ExecPolicyAmendment { + fn from(command: Vec) -> Self { + Self { command } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct SandboxCommandAssessment { pub description: String, @@ -51,10 +79,10 @@ pub struct ExecApprovalRequestEvent { /// Optional model-provided risk assessment describing the blocked command. #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, - /// Prefix rule that can be added to the user's execpolicy to allow future runs. + /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, type = "Array")] - pub allow_prefix: Option>, + #[ts(optional)] + pub proposed_execpolicy_amendment: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ef06c2e1dc..4089c79373 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -39,6 +39,7 @@ use ts_rs::TS; pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; +pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::SandboxCommandAssessment; pub use crate::approvals::SandboxRiskLevel; @@ -1655,9 +1656,11 @@ pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, - /// User has approved this command and wants to add the command prefix to - /// the execpolicy allow list so future matching commands are permitted. - ApprovedAllowPrefix { allow_prefix: Vec }, + /// User has approved this command and wants to apply the proposed execpolicy + /// amendment so future matching commands are permitted. + ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment, + }, /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 3defdbf9e3..7a8a64948e 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -17,6 +17,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_core::protocol::ElicitationAction; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; @@ -43,7 +44,7 @@ pub(crate) enum ApprovalRequest { command: Vec, reason: Option, risk: Option, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, }, ApplyPatch { id: String, @@ -105,8 +106,11 @@ impl ApprovalOverlay { header: Box, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { - ApprovalVariant::Exec { allow_prefix, .. } => ( - exec_options(allow_prefix.clone()), + ApprovalVariant::Exec { + proposed_execpolicy_amendment, + .. + } => ( + exec_options(proposed_execpolicy_amendment.clone()), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -337,7 +341,7 @@ impl From for ApprovalRequestState { command, reason, risk, - allow_prefix, + proposed_execpolicy_amendment, } => { let reason = reason.filter(|item| !item.is_empty()); let has_reason = reason.is_some(); @@ -360,7 +364,7 @@ impl From for ApprovalRequestState { variant: ApprovalVariant::Exec { id, command, - allow_prefix, + proposed_execpolicy_amendment, }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } @@ -437,7 +441,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, }, ApplyPatch { id: String, @@ -470,7 +474,7 @@ impl ApprovalOption { } } -fn exec_options(allow_prefix: Option>) -> Vec { +fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), @@ -486,10 +490,10 @@ fn exec_options(allow_prefix: Option>) -> Vec { }, ] .into_iter() - .chain(allow_prefix.map(|prefix| ApprovalOption { + .chain(proposed_execpolicy_amendment.map(|prefix| ApprovalOption { label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix { - allow_prefix: prefix, + decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, }), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], @@ -556,7 +560,7 @@ mod tests { command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } @@ -590,7 +594,7 @@ mod tests { } #[test] - fn exec_prefix_option_emits_allow_prefix() { + fn exec_prefix_option_emits_execpolicy_amendment() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let mut view = ApprovalOverlay::new( @@ -599,7 +603,9 @@ mod tests { command: vec!["echo".to_string()], reason: None, risk: None, - allow_prefix: Some(vec!["echo".to_string()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), }, tx, ); @@ -609,8 +615,10 @@ mod tests { if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { assert_eq!( decision, - ReviewDecision::ApprovedAllowPrefix { - allow_prefix: vec!["echo".to_string()] + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ + "echo".to_string() + ]) } ); saw_op = true; @@ -619,7 +627,7 @@ mod tests { } assert!( saw_op, - "expected approval decision to emit an op with allow prefix" + "expected approval decision to emit an op with command prefix" ); } @@ -633,7 +641,7 @@ mod tests { command, reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 844442c24c..5ebb492a26 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,7 +570,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4aa397ad90..2684fa9ff9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1074,7 +1074,7 @@ impl ChatWidget { command: ev.command, reason: ev.reason, risk: ev.risk, - allow_prefix: ev.allow_prefix, + proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2b75f960bd..abce9d4283 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -23,6 +23,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::FileChange; use codex_core::protocol::Op; @@ -688,7 +689,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -733,7 +734,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -784,7 +785,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1993,7 +1994,11 @@ fn approval_modal_exec_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2040,7 +2045,11 @@ fn approval_modal_exec_without_reason_snapshot() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, - allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2254,7 +2263,10 @@ fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: Some(vec!["echo".into(), "hello world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 90a54268cb..bdcaca7bea 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -409,7 +409,7 @@ pub fn new_approval_decision_cell( ], ) } - ApprovedAllowPrefix { .. } => { + ApprovedExecpolicyAmendment { .. } => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✔ ".green(), @@ -418,7 +418,7 @@ pub fn new_approval_decision_cell( "approved".bold(), " codex to run ".into(), snippet, - " and added its prefix to your allow list".bold(), + " and applied the execpolicy amendment".bold(), ], ) } diff --git a/docs/config.md b/docs/config.md index 6aa1bde8c5..3b06d73019 100644 --- a/docs/config.md +++ b/docs/config.md @@ -603,7 +603,7 @@ metadata above): - `codex.tool_decision` - `tool_name` - `call_id` - - `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`) + - `decision` (`approved`, `approved_execpolicy_amendment`, `approved_for_session`, `denied`, or `abort`) - `source` (`config` or `user`) - `codex.tool_result` - `tool_name` diff --git a/docs/execpolicy.md b/docs/execpolicy.md index a5b77e402e..d543a414e7 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -33,6 +33,30 @@ codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push ori Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax. +Example output when a rule matches: + +```json +{ + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "prompt" + } + } + ], + "decision": "prompt" +} +``` + +When no rules match, `matchedRules` is an empty array and `decision` is omitted. + +```json +{ + "matchedRules": [] +} +``` + ## Status `execpolicy` commands are still in preview. The API may have breaking changes in the future. From 871f44f385452d324bb465ea8949d94943347b85 Mon Sep 17 00:00:00 2001 From: ae Date: Wed, 3 Dec 2025 23:47:46 -0800 Subject: [PATCH 025/159] Add Enterprise plan to ChatGPT login description (#6918) ## Summary - update ChatGPT onboarding login description to mention Enterprise plans alongside Plus, Pro, and Team ## Testing - just fmt ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_691e088daf20832c88d8b667adf45128) --- codex-rs/tui/src/onboarding/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 04096ce0ec..86ddcfb64e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -217,7 +217,7 @@ impl AuthModeWidget { }; let chatgpt_description = if self.is_chatgpt_login_allowed() { - "Usage included with Plus, Pro, and Team plans" + "Usage included with Plus, Pro, Team, and Enterprise plans" } else { "ChatGPT login is disabled" }; From 87666695ba99a98f8eb26982394f314358f8597b Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:58:13 -0500 Subject: [PATCH 026/159] execpolicy tui flow (#7543) ## Updating the `execpolicy` TUI flow In the TUI, when going through the command approval flow, codex will now ask the user if they would like to whitelist the FIRST unmatched command among a chain of commands. For example, let's say the agent wants to run `apple | pear` with an empty `execpolicy` Neither apple nor pear will match to an `execpolicy` rule. Thus, when prompting the user, codex tui will ask the user if they would like to whitelist `apple`. If the agent wants to run `apple | pear` again, they would be prompted again because pear is still unknown. when prompted, the user will now be asked if they'd like to whitelist `pear`. Here's a demo video of this flow: https://github.com/user-attachments/assets/fd160717-f6cb-46b0-9f4a-f0a974d4e710 This PR also removed the `allow for this session` option from the TUI. --- .../tui/src/bottom_pane/approval_overlay.rs | 102 ++++++++++++------ codex-rs/tui/src/bottom_pane/mod.rs | 14 ++- codex-rs/tui/src/chatwidget.rs | 9 +- ...hatwidget__tests__approval_modal_exec.snap | 8 +- ..._tests__approval_modal_exec_no_reason.snap | 5 +- ...dget__tests__exec_approval_modal_exec.snap | 13 +-- ...sts__status_widget_and_approval_modal.snap | 28 +++-- codex-rs/tui/src/chatwidget/tests.rs | 20 ++-- 8 files changed, 120 insertions(+), 79 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 7a8a64948e..768fe030d4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -16,6 +16,8 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use codex_core::features::Feature; +use codex_core::features::Features; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; @@ -69,10 +71,11 @@ pub(crate) struct ApprovalOverlay { options: Vec, current_complete: bool, done: bool, + features: Features, } impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { + pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { let mut view = Self { current_request: None, current_variant: None, @@ -82,6 +85,7 @@ impl ApprovalOverlay { options: Vec::new(), current_complete: false, done: false, + features, }; view.set_current(request); view @@ -96,7 +100,7 @@ impl ApprovalOverlay { let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); self.current_variant = Some(variant.clone()); self.current_complete = false; - let (options, params) = Self::build_options(variant, header); + let (options, params) = Self::build_options(variant, header, &self.features); self.options = options; self.list = ListSelectionView::new(params, self.app_event_tx.clone()); } @@ -104,13 +108,14 @@ impl ApprovalOverlay { fn build_options( variant: ApprovalVariant, header: Box, + features: &Features, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { ApprovalVariant::Exec { proposed_execpolicy_amendment, .. } => ( - exec_options(proposed_execpolicy_amendment.clone()), + exec_options(proposed_execpolicy_amendment.clone(), features), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -474,30 +479,36 @@ impl ApprovalOption { } } -fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { - vec![ - ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "Yes, and don't ask again this session".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - }, - ] - .into_iter() - .chain(proposed_execpolicy_amendment.map(|prefix| ApprovalOption { - label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: prefix, - }), +fn exec_options( + proposed_execpolicy_amendment: Option, + features: &Features, +) -> Vec { + vec![ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], - })) + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }] + .into_iter() + .chain( + proposed_execpolicy_amendment + .filter(|_| features.enabled(Feature::ExecPolicy)) + .map(|prefix| { + let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); + ApprovalOption { + label: format!( + "Yes, and don't ask again for commands that start with `{rendered_prefix}`" + ), + decision: ApprovalDecision::Review( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, + }, + ), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + } + }), + ) .chain([ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), @@ -568,7 +579,7 @@ mod tests { fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); view.enqueue_request(make_exec_request()); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); assert!(view.queue.is_empty()); @@ -579,7 +590,7 @@ mod tests { fn shortcut_triggers_selection() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); // We expect at least one CodexOp message in the queue. @@ -608,6 +619,7 @@ mod tests { ])), }, tx, + Features::with_defaults(), ); view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); let mut saw_op = false; @@ -631,6 +643,33 @@ mod tests { ); } + #[test] + fn exec_prefix_option_hidden_when_execpolicy_disabled() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + risk: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + { + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + features + }, + ); + assert_eq!(view.options.len(), 2); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -644,7 +683,7 @@ mod tests { proposed_execpolicy_amendment: None, }; - let view = ApprovalOverlay::new(exec_request, tx); + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); @@ -694,8 +733,7 @@ mod tests { fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); - view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( @@ -710,6 +748,6 @@ mod tests { break; } } - assert_eq!(decision, Some(ReviewDecision::ApprovedForSession)); + assert_eq!(decision, Some(ReviewDecision::Approved)); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5ebb492a26..8a4336f6fe 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,6 +8,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use crossterm::event::KeyCode; @@ -409,7 +410,7 @@ impl BottomPane { } /// Called when the agent requests user approval. - pub fn push_approval_request(&mut self, request: ApprovalRequest) { + pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_approval_request(request) { Some(request) => request, @@ -423,7 +424,7 @@ impl BottomPane { }; // Otherwise create a new approval modal overlay. - let modal = ApprovalOverlay::new(request, self.app_event_tx.clone()); + let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } @@ -578,6 +579,7 @@ mod tests { fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -588,7 +590,7 @@ mod tests { animations_enabled: true, skills: Some(Vec::new()), }); - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); assert!(pane.ctrl_c_quit_hint_visible()); assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); @@ -600,6 +602,7 @@ mod tests { fn overlay_not_shown_above_approval_modal() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -612,7 +615,7 @@ mod tests { }); // Create an approval modal (active view). - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); // Render and verify the top row does not include an overlay. let area = Rect::new(0, 0, 60, 6); @@ -633,6 +636,7 @@ mod tests { fn composer_shown_after_denied_while_task_running() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -648,7 +652,7 @@ mod tests { pane.set_task_running(true); // Push an approval modal (e.g., command approval) which should hide the status view. - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); // Simulate pressing 'n' (No) on the modal. use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2684fa9ff9..8f9db3b9d9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1076,7 +1076,8 @@ impl ChatWidget { risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); } @@ -1093,7 +1094,8 @@ impl ChatWidget { changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.clone(), @@ -1113,7 +1115,8 @@ impl ChatWidget { request_id: ev.id, message: ev.message, }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index eaf0fed3a1..ca093f271a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -4,14 +4,12 @@ expression: terminal.backend().vt100().screen().contents() --- Would you like to run the following command? - Reason: this is a test reason such as one that would be produced by the - model + Reason: this is a test reason such as one that would be produced by the model $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again this session (a) - 3. Yes, and don't ask again for commands with this prefix (p) - 4. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index ce2277ce62..2bbe9aefcd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -7,8 +7,7 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again this session (a) - 3. Yes, and don't ask again for commands with this prefix (p) - 4. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index ff70d7d492..1c6a3ef136 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 14 }, + area: Rect { x: 0, y: 0, width: 80, height: 13 }, content: [ " ", " ", @@ -15,8 +15,7 @@ Buffer { " $ echo hello world ", " ", "› 1. Yes, proceed (y) ", - " 2. Yes, and don't ask again this session (a) ", - " 3. No, and tell Codex what to do differently (esc) ", + " 2. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", ], @@ -30,10 +29,8 @@ Buffer { x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 44, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 5ce388c87b..5e6e33dece 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -2,18 +2,16 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the " -" model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again this session (a) " -" 3. Yes, and don't ask again for commands with this prefix (p) " -" 4. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index abce9d4283..ef1de1fde3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2007,11 +2007,12 @@ fn approval_modal_exec_snapshot() { }); // Render to a fixed-size test terminal and snapshot. // Call desired_height first and use that exact height for rendering. - let height = chat.desired_height(80); + let width = 100; + let height = chat.desired_height(width); let mut terminal = - crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height)) + crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) .expect("create terminal"); - let viewport = Rect::new(0, 0, 80, height); + let viewport = Rect::new(0, 0, width, height); terminal.set_viewport_area(viewport); terminal @@ -2057,10 +2058,11 @@ fn approval_modal_exec_without_reason_snapshot() { msg: EventMsg::ExecApprovalRequest(ev), }); - let height = chat.desired_height(80); + let width = 100; + let height = chat.desired_height(width); let mut terminal = - ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 80, height)); + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal (no reason)"); @@ -2275,9 +2277,11 @@ fn status_widget_and_approval_modal_snapshot() { }); // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) + let width: u16 = 100; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) .expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status + approval modal"); From 8f4e00e1f1ec787523ef5979eac14792c20410b6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 15:13:49 +0000 Subject: [PATCH 027/159] chore: tool tip for /prompt (#7591) --- codex-rs/tui/tooltips.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 70c254d293..f4291f3bfc 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -5,7 +5,7 @@ Use /feedback to send logs to the maintainers when something looks off. Switch models or reasoning effort quickly with /model. You can run any shell commands from codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. -Use /prompts: key=value to expand a saved prompt with placeholders before sending. +You can define your own `/` commands with custom prompts. More info here: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts With the composer empty, press Esc to step back and edit your last message; Enter confirms. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` From c4e18f1b63fc2e880baa86befe60ca910135b12b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Dec 2025 08:32:54 -0800 Subject: [PATCH 028/159] Slightly better status display for unified exec (#7563) Trim bash -lc --- codex-rs/core/src/unified_exec/session_manager.rs | 7 ++++++- codex-rs/tui/src/exec_cell/render.rs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 51e5626073..0eab30dced 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -10,6 +10,7 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; use crate::exec::ExecToolCallOutput; @@ -516,7 +517,11 @@ impl UnifiedExecSessionManager { turn: &Arc, command: &[String], ) { - let command_display = command.join(" "); + let command_display = if let Some((_, script)) = extract_bash_command(command) { + script.to_string() + } else { + command.join(" ") + }; let message = format!("Waiting for `{command_display}`"); session .send_event( diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index a38cf5a84c..6517bcf470 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -14,6 +14,7 @@ use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; +use codex_core::bash::extract_bash_command; use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; use itertools::Itertools; @@ -58,7 +59,11 @@ pub(crate) fn new_active_exec_command( } fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { - let command_display = command.join(" "); + let command_display = if let Some((_, script)) = extract_bash_command(command) { + script.to_string() + } else { + command.join(" ") + }; match input { Some(data) if !data.is_empty() => { let preview = summarize_interaction_input(data); From f1b7cdc3bd3a03930456214b7fa7c49b3ba89260 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Dec 2025 08:34:09 -0800 Subject: [PATCH 029/159] Use shared check sandboxing (#7547) --- codex-rs/core/src/unified_exec/session.rs | 32 ++++--- .../core/src/unified_exec/session_manager.rs | 92 ++++++------------- 2 files changed, 47 insertions(+), 77 deletions(-) diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index a6e4167ade..02465538ec 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -154,10 +154,6 @@ impl UnifiedExecSession { } pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> { - if self.sandbox_type() == SandboxType::None || !self.has_exited() { - return Ok(()); - } - let _ = tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await; @@ -167,28 +163,40 @@ impl UnifiedExecSession { aggregated.extend_from_slice(&chunk); } let aggregated_text = String::from_utf8_lossy(&aggregated).to_string(); - let exit_code = self.exit_code().unwrap_or(-1); + self.check_for_sandbox_denial_with_text(&aggregated_text) + .await?; + Ok(()) + } + + pub(super) async fn check_for_sandbox_denial_with_text( + &self, + text: &str, + ) -> Result<(), UnifiedExecError> { + let sandbox_type = self.sandbox_type(); + if sandbox_type == SandboxType::None || !self.has_exited() { + return Ok(()); + } + + let exit_code = self.exit_code().unwrap_or(-1); let exec_output = ExecToolCallOutput { exit_code, - stdout: StreamOutput::new(aggregated_text.clone()), - aggregated_output: StreamOutput::new(aggregated_text.clone()), + stderr: StreamOutput::new(text.to_string()), + aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - - if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) { + if is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( - &aggregated_text, + text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), ); let message = if snippet.is_empty() { - format!("Session creation failed with exit code {exit_code}") + format!("Session exited with code {exit_code}") } else { snippet }; return Err(UnifiedExecError::sandbox_denied(message, exec_output)); } - Ok(()) } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 0eab30dced..da9cb338d7 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -154,10 +154,24 @@ impl UnifiedExecSessionManager { let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); let has_exited = session.has_exited(); let exit_code = session.exit_code(); - let sandbox_type = session.sandbox_type(); let chunk_id = generate_chunk_id(); - let process_id = if has_exited { - None + let process_id = request.process_id.clone(); + if has_exited { + let exit = exit_code.unwrap_or(-1); + Self::emit_exec_end_from_context( + context, + &request.command, + cwd, + output.clone(), + exit, + wall_time, + // We always emit the process ID in order to keep consistency between the Begin + // event and the End event. + Some(process_id), + ) + .await; + + session.check_for_sandbox_denial_with_text(&text).await?; } else { // Only store session if not exited. self.store_session( @@ -166,48 +180,29 @@ impl UnifiedExecSessionManager { &request.command, cwd.clone(), start, - request.process_id.clone(), + process_id, ) .await; - Some(request.process_id.clone()) - }; - let original_token_count = approx_token_count(&text); + Self::emit_waiting_status(&context.session, &context.turn, &request.command).await; + }; + + let original_token_count = approx_token_count(&text); let response = UnifiedExecResponse { event_call_id: context.call_id.clone(), chunk_id, wall_time, output, - process_id: process_id.clone(), + process_id: if has_exited { + None + } else { + Some(request.process_id.clone()) + }, exit_code, original_token_count: Some(original_token_count), session_command: Some(request.command.clone()), }; - if !has_exited { - Self::emit_waiting_status(&context.session, &context.turn, &request.command).await; - } - - // If the command completed during this call, emit an ExecCommandEnd via the emitter. - if has_exited { - let exit = response.exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, - cwd, - response.output.clone(), - exit, - response.wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(request.process_id), - ) - .await; - - // Exit code should always be Some - sandboxing::check_sandboxing(sandbox_type, &text, exit_code.unwrap_or_default())?; - } - Ok(response) } @@ -714,39 +709,6 @@ impl UnifiedExecSessionManager { } } -mod sandboxing { - use super::*; - use crate::exec::SandboxType; - use crate::exec::is_likely_sandbox_denied; - use crate::unified_exec::UNIFIED_EXEC_OUTPUT_MAX_TOKENS; - - pub(crate) fn check_sandboxing( - sandbox_type: SandboxType, - text: &str, - exit_code: i32, - ) -> Result<(), UnifiedExecError> { - let exec_output = ExecToolCallOutput { - exit_code, - stderr: StreamOutput::new(text.to_string()), - aggregated_output: StreamOutput::new(text.to_string()), - ..Default::default() - }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { - let snippet = formatted_truncate_text( - text, - TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), - ); - let message = if snippet.is_empty() { - format!("Session exited with code {exit_code}") - } else { - snippet - }; - return Err(UnifiedExecError::sandbox_denied(message, exec_output)); - } - Ok(()) - } -} - enum SessionStatus { Alive { exit_code: Option, From 1b2509f05ab950e8b2b92637b3d91172e238196c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 17:29:23 +0000 Subject: [PATCH 030/159] chore: default warning messages to true (#7588) --- codex-rs/core/src/features.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 687e8b1627..1d775360c4 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -27,16 +27,23 @@ pub enum Stage { /// Unique features toggled via configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { + // Stable. /// Create a ghost commit at each turn. GhostCommit, + /// Include the view_image tool. + ViewImageTool, + /// Send warnings to the model to correct it on the tool usage. + ModelWarnings, + /// Enable the default shell tool. + ShellTool, + + // Experimental /// Use the single unified PTY-backed exec tool. UnifiedExec, /// Enable experimental RMCP features such as OAuth login. RmcpClient, /// Include the freeform apply_patch tool. ApplyPatchFreeform, - /// Include the view_image tool. - ViewImageTool, /// Allow the model to request web searches. WebSearchRequest, /// Gate the execpolicy enforcement for shell/unified exec. @@ -47,14 +54,10 @@ pub enum Feature { WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) RemoteCompaction, - /// Enable the default shell tool. - ShellTool, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). Skills, - /// Send warnings to the model to correct it on the tool usage. - ModelWarnings, } impl Feature { @@ -275,6 +278,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ModelWarnings, + key: "warnings", + stage: Stage::Stable, + default_enabled: true, + }, // Unstable features. FeatureSpec { id: Feature::UnifiedExec, @@ -330,12 +339,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::ModelWarnings, - key: "warnings", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::Skills, key: "skills", From 36edb412b14a821711d30116e5ec11807ce83201 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 17:42:16 +0000 Subject: [PATCH 031/159] fix: release session ID when not used (#7592) --- codex-rs/core/src/tools/handlers/unified_exec.rs | 2 ++ .../core/src/unified_exec/session_manager.rs | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 4c943c6285..f2500a413b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -146,6 +146,7 @@ impl ToolHandler for UnifiedExecHandler { codex_protocol::protocol::AskForApproval::OnRequest ) { + manager.release_process_id(&process_id).await; return Err(FunctionCallError::RespondToModel(format!( "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", policy = context.turn.approval_policy @@ -169,6 +170,7 @@ impl ToolHandler for UnifiedExecHandler { ) .await? { + manager.release_process_id(&process_id).await; return Ok(output); } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index da9cb338d7..88d65ca142 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -111,6 +111,11 @@ impl UnifiedExecSessionManager { } } + pub(crate) async fn release_process_id(&self, process_id: &str) { + let mut store = self.session_store.lock().await; + store.remove(process_id); + } + pub(crate) async fn exec_command( &self, request: ExecCommandRequest, @@ -129,7 +134,15 @@ impl UnifiedExecSessionManager { request.justification, context, ) - .await?; + .await; + + let session = match session { + Ok(session) => session, + Err(err) => { + self.release_process_id(&request.process_id).await; + return Err(err); + } + }; let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); @@ -157,6 +170,7 @@ impl UnifiedExecSessionManager { let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); Self::emit_exec_end_from_context( context, From 404a1ea34bbb4c4bef469bef134455a983f4f9bc Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 09:55:42 -0800 Subject: [PATCH 032/159] Update execpolicy.md (#7595) --- docs/execpolicy.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/execpolicy.md b/docs/execpolicy.md index d543a414e7..48df2506f8 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -1,8 +1,20 @@ # Execpolicy quickstart -Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in Starlark `.codexpolicy` files under `~/.codex/policy`. +Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in `.codexpolicy` files under `~/.codex/policy`. -## Create a policy +## How to create and edit rules + +### TUI interactions + +Codex CLI will present the option to whitelist commands when a command causes a prompt. + +Screenshot 2025-12-04 at 9 23 54 AM + +Whitelisted commands will no longer require your permission to run in current and subsequent sessions. + +Under the hood, when you approve and whitelist a command, codex will edit `~/.codex/policy/default.execpolicy`. + +### Editing `.codexpolicy` files 1. Create a policy directory: `mkdir -p ~/.codex/policy`. 2. Add one or more `.codexpolicy` files in that folder. Codex automatically loads every `.codexpolicy` file in there on startup. From 2b5d0b2935209dee8c1d07c9ee02f3899705c63b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 17:58:58 +0000 Subject: [PATCH 033/159] feat: update sandbox policy to allow TTY (#7580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Change**: Seatbelt now allows file-ioctl on /dev/ttys[0-9]+ even without the sandbox extension so pre-created PTYs remain interactive (Python REPL, shells). **Risk**: A seatbelted process that already holds a PTY fd (including one it shouldn’t) could issue tty ioctls like TIOCSTI or termios changes on that fd. This doesn’t allow opening new PTYs or reading/writing them; it only broadens ioctl capability on existing fds. **Why acceptable**: We already hand the child its PTY for interactive use; restoring ioctls is required for isatty() and prompts to work. The attack requires being given or inheriting a sensitive PTY fd; by design we don’t hand untrusted processes other users’ PTYs (we don't hand them any PTYs actually), so the practical exposure is limited to the PTY intentionally allocated for the session. **Validation**: Running ``` start a python interpreter and keep it running ``` Followed by: * `calculate 1+1 using it` -> works as expected * `Use this Python session to run the command just fix in /Users/jif/code/codex/codex-rs` -> does not work as expected --- codex-rs/core/src/seatbelt_base_policy.sbpl | 3 + codex-rs/core/tests/suite/unified_exec.rs | 124 ++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index 8ccfa6e824..236f7a1398 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -102,3 +102,6 @@ (require-all (regex #"^/dev/ttys[0-9]+") (extension "com.apple.sandbox.pty"))) +; PTYs created before entering seatbelt may lack the extension; allow ioctl +; on those slave ttys so interactive shells detect a TTY and remain functional. +(allow file-ioctl (regex #"^/dev/ttys[0-9]+")) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index dc7bdb6b10..5e8f5a8cdf 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1943,6 +1943,130 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let python = match which::which("python").or_else(|_| which::which("python3")) { + Ok(path) => path, + Err(_) => { + eprintln!("python not found in PATH, skipping test."); + return Ok(()); + } + }; + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let startup_call_id = "uexec-python-seatbelt"; + let startup_args = serde_json::json!({ + "cmd": format!("{} -i", python.display()), + "yield_time_ms": 750, + }); + + let exit_call_id = "uexec-python-exit"; + let exit_args = serde_json::json!({ + "chars": "exit()\n", + "session_id": 1000, + "yield_time_ms": 750, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + startup_call_id, + "exec_command", + &serde_json::to_string(&startup_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + exit_call_id, + "write_stdin", + &serde_json::to_string(&exit_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "start python under seatbelt".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let startup_output = outputs + .get(startup_call_id) + .expect("missing python startup output"); + + let output_text = startup_output.output.replace("\r\n", "\n"); + // This assert that we are in a TTY. + assert!( + output_text.contains(">>>"), + "python prompt missing from seatbelt output: {output_text:?}" + ); + + assert_eq!( + startup_output.process_id.as_deref(), + Some("1000"), + "python session should stay alive for follow-up input" + ); + + let exit_output = outputs + .get(exit_call_id) + .expect("missing python exit output"); + + assert_eq!( + exit_output.exit_code, + Some(0), + "python should exit cleanly after exit()" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { From 291b54a762db8f76402d4a854ad342679cc22806 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Dec 2025 18:01:12 +0000 Subject: [PATCH 034/159] chore: review in read-only (#7593) --- codex-rs/core/src/tasks/review.rs | 2 ++ codex-rs/core/tests/suite/review.rs | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 738d33c529..5c2e8d08b9 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -16,6 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex_delegate::run_codex_conversation_one_shot; +use crate::protocol::SandboxPolicy; use crate::review_format::format_review_findings_block; use crate::review_format::render_review_output_text; use crate::state::TaskKind; @@ -77,6 +78,7 @@ async fn start_review_conversation( ) -> Option> { let config = ctx.client.config(); let mut sub_agent_config = config.as_ref().clone(); + sub_agent_config.sandbox_policy = SandboxPolicy::new_read_only_policy(); // Run with only reviewer rubric — drop outer user_instructions sub_agent_config.user_instructions = None; // Avoid loading project docs; reviewer only needs findings diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 7216e19251..b3a52cfa54 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -573,6 +573,10 @@ async fn review_input_isolated_from_parent_history() { review_prompt, "user message should only contain the raw review prompt" ); + assert!( + env_text.contains("read-only"), + "review environment context must run with read-only sandbox" + ); // Ensure the REVIEW_PROMPT rubric is sent via instructions. let instructions = body["instructions"].as_str().expect("instructions string"); From 37c36024c78f02c2ae274f63768906b0eaa07d2c Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 4 Dec 2025 10:39:45 -0800 Subject: [PATCH 035/159] chore(core): test apply_patch_cli on Windows (#7554) ## Summary These tests pass on windows, let's enable them. ## Testing - [x] These are more tests --- codex-rs/core/tests/suite/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 35d4eb52a4..86f417801a 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -15,7 +15,6 @@ pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe { #[cfg(not(target_os = "windows"))] mod abort_tasks; -#[cfg(not(target_os = "windows"))] mod apply_patch_cli; #[cfg(not(target_os = "windows"))] mod approvals; From ce0b38c05613bda491b3aed0dfd9f9dca4c6e558 Mon Sep 17 00:00:00 2001 From: Maxime Savard Date: Thu, 4 Dec 2025 13:50:20 -0500 Subject: [PATCH 036/159] FIX: WSL Paste image does not work (#6793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Related issues: - https://github.com/openai/codex/issues/3939 - https://github.com/openai/codex/issues/2292 - https://github.com/openai/codex/issues/7528 (After correction https://github.com/openai/codex/pull/3990) **Area:** `codex-cli` (image handling / clipboard & file uploads) **Platforms affected:** WSL (Ubuntu on Windows 10/11). No behavior change on native Linux/macOS/Windows. ## Summary This PR fixes image pasting and file uploads when running `codex-cli` inside WSL. Previously, image operations failed silently or with permission errors because paths weren't properly mapped between Windows and WSL filesystems. ## Visual Result image ## Last Rust-Cli image ## Root cause The CLI assumed native Linux/Windows environments and didn't handle the WSL↔Windows boundary: - Used Linux paths for files that lived on the Windows host - Missing path normalization between Windows (`C:\...`) and WSL (`/mnt/c/...`) - Clipboard access failed under WSL ### Why `Ctrl+V` doesn't work in WSL terminals Most WSL terminal emulators (Windows Terminal, ConEmu, etc.) intercept `Ctrl+V` at the terminal level to paste text from the Windows clipboard. This keypress never reaches the CLI application itself, so our clipboard image handler never gets triggered. Users need `Ctrl+Alt+V`. ## Changes ### WSL detection & path mapping - Detects WSL by checking `/proc/sys/kernel/osrelease` and the `WSL_INTEROP` env var - Maps Windows drive paths to WSL mount paths (`C:\...` → `/mnt/c/...`) ### Clipboard fallback for WSL - When clipboard access fails under WSL, falls back to PowerShell to extract images from the Windows clipboard - Saves to a temp file and maps the path back to WSL ### UI improvements - Shows `Ctrl+Alt+V` hint on WSL (many terminals intercept plain `Ctrl+V`) - Better error messages for unreadable images ## Performance - Negligible overhead. The fallback adds a single FS copy to a temp file only when needed. - Direct streaming remains the default. ## Files changed - `protocol/src/lib.rs` – Added platform detection module - `protocol/src/models.rs` – Added WSL path mapping for local images - `protocol/src/platform.rs` – New module with WSL detection utilities - `tui/src/bottom_pane/chat_composer.rs` – Added base64 data URL support and WSL path mapping - `tui/src/bottom_pane/footer.rs` – WSL-aware keyboard shortcuts - `tui/src/clipboard_paste.rs` – PowerShell clipboard fallback ## How to reproduce the original bug (pre-fix) 1. Run `codex-cli` inside WSL2 on Windows. 2. Paste an image from the Windows clipboard or drag an image from `C:\...` into the terminal. 3. Observe that the image is not attached (silent failure) or an error is logged; no artifact reaches the tool. ## How to verify the fix 1. Build this branch and run `codex-cli` inside WSL2. 2. Paste from clipboard and drag from both Windows and WSL paths. 3. Confirm that the image appears in the tool and the CLI shows a single concise info line (no warning unless fallback was used). I’m happy to adjust paths, naming, or split helpers into a separate module if you prefer. ## How to try this branch If you want to try this before it’s merged, you can use my Git branch: Repository: https://github.com/Waxime64/codex.git Branch: `wsl-image-2` 1. Start WSL on your Windows machine. 2. Clone the repository and switch to the branch: ```bash git clone https://github.com/Waxime64/codex.git cd codex git checkout wsl-image-2 # then go into the Rust workspace root, e.g.: cd codex-rs 3. Build the TUI binary: cargo build -p codex-tui --bin codex-tui --release 4. Install the binary: sudo install -m 0755 target/release/codex-tui /usr/local/bin/codex 5. From the project directory where you want to use Codex, start it with: cd /path/to/your/project /usr/local/bin/codex On WSL, use CTRL+ALT+V to paste an image from the Windows clipboard into the chat. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 + codex-rs/tui/src/bottom_pane/footer.rs | 38 +++- codex-rs/tui/src/clipboard_paste.rs | 179 ++++++++++++------ codex-rs/tui/src/key_hint.rs | 4 + 4 files changed, 162 insertions(+), 61 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4529b66566..4eeeb4bcee 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -257,6 +257,8 @@ impl ChatComposer { return false; }; + // normalize_pasted_path already handles Windows → WSL path conversion, + // so we can directly try to read the image dimensions. match image::image_dimensions(&path_buf) { Ok((w, h)) => { tracing::info!("OK: {pasted}"); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 11a97c783b..d47ffec98b 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +use crate::clipboard_paste::is_probably_wsl; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::line_utils::prefix_lines; @@ -94,10 +96,19 @@ fn footer_lines(props: FooterProps) -> Vec> { ]); vec![line] } - FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - }), + FooterMode::ShortcutOverlay => { + #[cfg(target_os = "linux")] + let is_wsl = is_probably_wsl(); + #[cfg(not(target_os = "linux"))] + let is_wsl = false; + + let state = ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_wsl, + }; + shortcut_overlay_lines(state) + } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], FooterMode::ContextOnly => vec![context_window_line( props.context_window_percent, @@ -115,6 +126,7 @@ struct CtrlCReminderState { struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, + is_wsl: bool, } fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { @@ -271,6 +283,7 @@ enum DisplayCondition { Always, WhenShiftEnterHint, WhenNotShiftEnterHint, + WhenUnderWSL, } impl DisplayCondition { @@ -279,6 +292,7 @@ impl DisplayCondition { DisplayCondition::Always => true, DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + DisplayCondition::WhenUnderWSL => state.is_wsl, } } } @@ -352,10 +366,18 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ShortcutDescriptor { id: ShortcutId::PasteImage, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('v')), - condition: DisplayCondition::Always, - }], + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain + // Ctrl+V); otherwise fall back to Ctrl+V. + bindings: &[ + ShortcutBinding { + key: key_hint::ctrl_alt(KeyCode::Char('v')), + condition: DisplayCondition::WhenUnderWSL, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('v')), + condition: DisplayCondition::Always, + }, + ], prefix: "", label: " to paste images", }, diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 2a669f5c4f..5863c728b0 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::path::PathBuf; use tempfile::Builder; -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum PasteImageError { ClipboardUnavailable(String), NoImage(String), @@ -119,19 +119,113 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro /// Convenience: write to a temp file and return its path + info. #[cfg(not(target_os = "android"))] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - let (png, info) = paste_image_as_png()?; - // Create a unique temporary file with a .png suffix to avoid collisions. - let tmp = Builder::new() - .prefix("codex-clipboard-") - .suffix(".png") - .tempfile() - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?; - // Persist the file (so it remains after the handle is dropped) and return its PathBuf. - let (_file, path) = tmp - .keep() - .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; - Ok((path, info)) + // First attempt: read image from system clipboard via arboard (native paths or image data). + match paste_image_as_png() { + Ok((png, info)) => { + // Create a unique temporary file with a .png suffix to avoid collisions. + let tmp = Builder::new() + .prefix("codex-clipboard-") + .suffix(".png") + .tempfile() + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + std::fs::write(tmp.path(), &png) + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + // Persist the file (so it remains after the handle is dropped) and return its PathBuf. + let (_file, path) = tmp + .keep() + .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; + Ok((path, info)) + } + Err(e) => { + #[cfg(target_os = "linux")] + { + try_wsl_clipboard_fallback(&e).or(Err(e)) + } + #[cfg(not(target_os = "linux"))] + { + Err(e) + } + } + } +} + +/// Attempt WSL fallback for clipboard image paste. +/// +/// If clipboard is unavailable (common under WSL because arboard cannot access +/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the +/// Windows side to write the clipboard image to a temporary file, then return +/// the corresponding WSL path. +#[cfg(target_os = "linux")] +fn try_wsl_clipboard_fallback( + error: &PasteImageError, +) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + use PasteImageError::ClipboardUnavailable; + use PasteImageError::NoImage; + + if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { + return Err(error.clone()); + } + + tracing::debug!("attempting Windows PowerShell clipboard fallback"); + let Some(win_path) = try_dump_windows_clipboard_image() else { + return Err(error.clone()); + }; + + tracing::debug!("powershell produced path: {}", win_path); + let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { + return Err(error.clone()); + }; + + let Ok((w, h)) = image::image_dimensions(&mapped_path) else { + return Err(error.clone()); + }; + + // Return the mapped path directly without copying. + // The file will be read and base64-encoded during serialization. + Ok(( + mapped_path, + PastedImageInfo { + width: w, + height: h, + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Try to call a Windows PowerShell command (several common names) to save the +/// clipboard image to a temporary PNG and return the Windows path to that file. +/// Returns None if no command succeeded or no image was present. +#[cfg(target_os = "linux")] +fn try_dump_windows_clipboard_image() -> Option { + // Powershell script: save image from clipboard to a temp png and print the path. + // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) + // and pwsh (UTF-8 default). + let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; + + for cmd in ["powershell.exe", "pwsh", "powershell"] { + match std::process::Command::new(cmd) + .args(["-NoProfile", "-Command", script]) + .output() + { + // Executing PowerShell command + Ok(output) => { + if output.status.success() { + // Decode as UTF-8 (forced by the script above). + let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !win_path.is_empty() { + tracing::debug!("{} saved clipboard image to {}", cmd, win_path); + return Some(win_path); + } + } else { + tracing::debug!("{} returned non-zero status", cmd); + } + } + Err(err) => { + tracing::debug!("{} not executable: {}", cmd, err); + } + } + } + None } #[cfg(target_os = "android")] @@ -202,10 +296,19 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { } #[cfg(target_os = "linux")] -fn is_probably_wsl() -> bool { - std::env::var_os("WSL_DISTRO_NAME").is_some() - || std::env::var_os("WSL_INTEROP").is_some() - || std::env::var_os("WSLENV").is_some() +pub(crate) fn is_probably_wsl() -> bool { + // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). + if let Ok(version) = std::fs::read_to_string("/proc/version") { + let version_lower = version.to_lowercase(); + if version_lower.contains("microsoft") || version_lower.contains("wsl") { + return true; + } + } + + // Fallback: Check WSL environment variables. This handles edge cases like + // custom Linux kernels installed in WSL where /proc/version may not contain + // "microsoft" or "WSL". + std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() } #[cfg(target_os = "linux")] @@ -253,40 +356,6 @@ pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { #[cfg(test)] mod pasted_paths_tests { use super::*; - #[cfg(target_os = "linux")] - use std::ffi::OsString; - - #[cfg(target_os = "linux")] - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - #[cfg(target_os = "linux")] - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, original } - } - } - - #[cfg(target_os = "linux")] - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(original) = &self.original { - unsafe { - std::env::set_var(self.key, original); - } - } else { - unsafe { - std::env::remove_var(self.key); - } - } - } - } #[cfg(not(windows))] #[test] @@ -420,7 +489,11 @@ mod pasted_paths_tests { #[cfg(target_os = "linux")] #[test] fn normalize_windows_path_in_wsl() { - let _guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu-24.04"); + // This test only runs on actual WSL systems + if !is_probably_wsl() { + // Skip test if not on WSL + return; + } let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); assert_eq!( diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 6272ab0dfe..515419ee04 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -49,6 +49,10 @@ pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::CONTROL) } +pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT)) +} + fn modifiers_to_string(modifiers: KeyModifiers) -> String { let mut result = String::new(); if modifiers.contains(KeyModifiers::CONTROL) { From 9b2055586d450fc94ad4a530aebdffe17f303d9b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Dec 2025 11:57:58 -0800 Subject: [PATCH 037/159] remove `model_family` from `config (#7571) - Remove `model_family` from `config` - Make sure to still override config elements related to `model_family` like supporting reasoning --- codex-rs/common/src/config_summary.rs | 13 +- codex-rs/core/src/client.rs | 39 +++--- codex-rs/core/src/codex.rs | 56 +++++--- codex-rs/core/src/config/mod.rs | 35 ++--- .../core/src/openai_models/model_family.rs | 13 ++ .../core/src/openai_models/models_manager.rs | 6 +- codex-rs/core/src/sandboxing/assessment.rs | 8 +- codex-rs/core/src/truncate.rs | 4 +- .../core/tests/chat_completions_payload.rs | 9 +- codex-rs/core/tests/chat_completions_sse.rs | 10 +- codex-rs/core/tests/common/test_codex.rs | 2 + codex-rs/core/tests/responses_headers.rs | 126 +++++++++++++++++- codex-rs/core/tests/suite/client.rs | 10 +- codex-rs/core/tests/suite/prompt_caching.rs | 15 ++- .../core/tests/suite/shell_serialization.rs | 69 ++++------ codex-rs/tui/src/app.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 5 +- codex-rs/tui/src/history_cell.rs | 71 +++++++--- 18 files changed, 339 insertions(+), 158 deletions(-) diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 8fc1bb26f3..32b837f1f5 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -12,15 +12,14 @@ pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, Stri ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; - if config.model_provider.wire_api == WireApi::Responses - && config.model_family.supports_reasoning_summaries - { + if config.model_provider.wire_api == WireApi::Responses { let reasoning_effort = config .model_reasoning_effort - .or(config.model_family.default_reasoning_effort) - .map(|effort| effort.to_string()) - .unwrap_or_else(|| "none".to_string()); - entries.push(("reasoning effort", reasoning_effort)); + .map(|effort| effort.to_string()); + entries.push(( + "reasoning effort", + reasoning_effort.unwrap_or_else(|| "none".to_string()), + )); entries.push(( "reasoning summaries", config.model_reasoning_summary.to_string(), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a3c990cdba..4c3cf737b2 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -57,6 +57,7 @@ use crate::tools::spec::create_tools_json_for_responses_api; pub struct ModelClient { config: Arc, auth_manager: Option>, + model_family: ModelFamily, otel_event_manager: OtelEventManager, provider: ModelProviderInfo, conversation_id: ConversationId, @@ -70,6 +71,7 @@ impl ModelClient { pub fn new( config: Arc, auth_manager: Option>, + model_family: ModelFamily, otel_event_manager: OtelEventManager, provider: ModelProviderInfo, effort: Option, @@ -80,6 +82,7 @@ impl ModelClient { Self { config, auth_manager, + model_family, otel_event_manager, provider, conversation_id, @@ -90,16 +93,18 @@ impl ModelClient { } pub fn get_model_context_window(&self) -> Option { - let pct = self.config.model_family.effective_context_window_percent; + let model_family = self.get_model_family(); + let effective_context_window_percent = model_family.effective_context_window_percent; self.config .model_context_window - .or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window)) - .map(|w| w.saturating_mul(pct) / 100) + .or_else(|| get_model_info(&model_family).map(|info| info.context_window)) + .map(|w| w.saturating_mul(effective_context_window_percent) / 100) } pub fn get_auto_compact_token_limit(&self) -> Option { + let model_family = self.get_model_family(); self.config.model_auto_compact_token_limit.or_else(|| { - get_model_info(&self.config.model_family).and_then(|info| info.auto_compact_token_limit) + get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit) }) } @@ -149,9 +154,8 @@ impl ModelClient { } let auth_manager = self.auth_manager.clone(); - let instructions = prompt - .get_full_instructions(&self.config.model_family) - .into_owned(); + let model_family = self.get_model_family(); + let instructions = prompt.get_full_instructions(&model_family).into_owned(); let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; let api_prompt = build_api_prompt(prompt, instructions, tools_json); let conversation_id = self.conversation_id.to_string(); @@ -204,16 +208,13 @@ impl ModelClient { } let auth_manager = self.auth_manager.clone(); - let instructions = prompt - .get_full_instructions(&self.config.model_family) - .into_owned(); + let model_family = self.get_model_family(); + let instructions = prompt.get_full_instructions(&model_family).into_owned(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; - let reasoning = if self.config.model_family.supports_reasoning_summaries { + let reasoning = if model_family.supports_reasoning_summaries { Some(Reasoning { - effort: self - .effort - .or(self.config.model_family.default_reasoning_effort), + effort: self.effort.or(model_family.default_reasoning_effort), summary: Some(self.summary), }) } else { @@ -226,15 +227,15 @@ impl ModelClient { vec![] }; - let verbosity = if self.config.model_family.support_verbosity { + let verbosity = if model_family.support_verbosity { self.config .model_verbosity - .or(self.config.model_family.default_verbosity) + .or(model_family.default_verbosity) } else { if self.config.model_verbosity.is_some() { warn!( "model_verbosity is set but ignored as the model does not support verbosity: {}", - self.config.model_family.family + model_family.family ); } None @@ -305,7 +306,7 @@ impl ModelClient { /// Returns the currently configured model family. pub fn get_model_family(&self) -> ModelFamily { - self.config.model_family.clone() + self.model_family.clone() } /// Returns the current reasoning effort setting. @@ -342,7 +343,7 @@ impl ModelClient { .with_telemetry(Some(request_telemetry)); let instructions = prompt - .get_full_instructions(&self.config.model_family) + .get_full_instructions(&self.get_model_family()) .into_owned(); let payload = ApiCompactionInput { model: &self.config.model, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 34cde906ec..94951c055b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -398,7 +398,7 @@ pub(crate) struct SessionSettingsUpdate { impl Session { fn make_turn_context( auth_manager: Option>, - models_manager: &ModelsManager, + models_manager: Arc, otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, @@ -407,13 +407,13 @@ impl Session { ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); let features = &config.features; - let model_family = models_manager.construct_model_family(&session_configuration.model); let mut per_turn_config = (*config).clone(); per_turn_config.model = session_configuration.model.clone(); - per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = features.clone(); + let model_family = + models_manager.construct_model_family(&per_turn_config.model, &per_turn_config); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } @@ -426,6 +426,7 @@ impl Session { let client = ModelClient::new( Arc::new(per_turn_config.clone()), auth_manager, + model_family.clone(), otel_event_manager, provider, session_configuration.model_reasoning_effort, @@ -455,7 +456,10 @@ impl Session { codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), exec_policy: session_configuration.exec_policy.clone(), - truncation_policy: TruncationPolicy::new(&per_turn_config), + truncation_policy: TruncationPolicy::new( + &per_turn_config, + model_family.truncation_policy, + ), } } @@ -539,10 +543,12 @@ impl Session { }); } + let model_family = models_manager.construct_model_family(&config.model, &config); + // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), @@ -762,7 +768,7 @@ impl Session { let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), - &self.services.models_manager, + Arc::clone(&self.services.models_manager), &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -862,6 +868,7 @@ impl Session { auth_manager, &otel, self.conversation_id, + self.services.models_manager.clone(), turn_context.client.get_session_source(), call_id, command, @@ -1891,7 +1898,10 @@ async fn spawn_review_thread( resolved: crate::review_prompts::ResolvedReviewRequest, ) { let model = config.review_model.clone(); - let review_model_family = sess.services.models_manager.construct_model_family(&model); + let review_model_family = sess + .services + .models_manager + .construct_model_family(&model, &config); // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -1911,7 +1921,6 @@ async fn spawn_review_thread( // Build per‑turn client with the requested model/family. let mut per_turn_config = (*config).clone(); per_turn_config.model = model.clone(); - per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); @@ -1924,13 +1933,14 @@ async fn spawn_review_thread( .get_otel_event_manager() .with_model( per_turn_config.model.as_str(), - per_turn_config.model_family.slug.as_str(), + review_model_family.slug.as_str(), ); let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( per_turn_config.clone(), auth_manager, + model_family.clone(), otel_event_manager, provider, per_turn_config.model_reasoning_effort, @@ -1955,7 +1965,7 @@ async fn spawn_review_thread( codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), exec_policy: parent_turn_context.exec_policy.clone(), - truncation_policy: TruncationPolicy::new(&per_turn_config), + truncation_policy: TruncationPolicy::new(&per_turn_config, model_family.truncation_policy), }; // Seed the child task with the review prompt as the initial user message. @@ -2149,10 +2159,7 @@ async fn run_turn( let mut base_instructions = turn_context.base_instructions.clone(); if parallel_tool_calls { static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - let family = sess - .services - .models_manager - .construct_model_family(&sess.state.lock().await.session_configuration.model); + let family = turn_context.client.get_model_family(); let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); new_instructions.push_str(INSTRUCTIONS); base_instructions = Some(new_instructions); @@ -2787,11 +2794,18 @@ mod tests { }) } - fn otel_event_manager(conversation_id: ConversationId, config: &Config) -> OtelEventManager { + fn otel_event_manager( + conversation_id: ConversationId, + config: &Config, + models_manager: &ModelsManager, + ) -> OtelEventManager { OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + models_manager + .construct_model_family(&config.model, config) + .slug + .as_str(), None, Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), @@ -2811,13 +2825,14 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); - let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let auth_manager = AuthManager::shared( config.cwd.clone(), false, config.cli_auth_credentials_store_mode, ); let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &models_manager); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2854,7 +2869,7 @@ mod tests { let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), - &models_manager, + models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -2892,13 +2907,14 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); - let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); let auth_manager = AuthManager::shared( config.cwd.clone(), false, config.cli_auth_credentials_store_mode, ); let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &models_manager); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2935,7 +2951,7 @@ mod tests { let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), - &models_manager, + models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index def63df845..a1cc46cf23 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -27,7 +27,6 @@ use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; -use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; @@ -80,9 +79,6 @@ pub struct Config { /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, - // todo(aibrahim): remove this field - pub model_family: ModelFamily, - /// Size of the context window for the model, in tokens. pub model_context_window: Option, @@ -195,6 +191,7 @@ pub struct Config { /// Additional filenames to try when looking for project-level docs. pub project_doc_fallback_filenames: Vec, + // todo(aibrahim): this should be used in the override model family /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, @@ -225,6 +222,12 @@ pub struct Config { /// request using the Responses API. pub model_reasoning_summary: ReasoningSummary, + /// Optional override to force-enable reasoning summaries for the configured model. + pub model_supports_reasoning_summaries: Option, + + /// Optional override to force reasoning summary format for the configured model. + pub model_reasoning_summary_format: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). pub model_verbosity: Option, @@ -1108,14 +1111,7 @@ impl Config { .or(cfg.model) .unwrap_or_else(default_model); - let mut model_family = find_family_for_model(&model); - - if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries { - model_family.supports_reasoning_summaries = supports_reasoning_summaries; - } - if let Some(model_reasoning_summary_format) = cfg.model_reasoning_summary_format { - model_family.reasoning_summary_format = model_reasoning_summary_format; - } + let model_family = find_family_for_model(&model); let openai_model_info = get_model_info(&model_family); let model_context_window = cfg @@ -1172,7 +1168,6 @@ impl Config { let config = Self { model, review_model, - model_family, model_context_window, model_auto_compact_token_limit, model_provider_id, @@ -1228,6 +1223,8 @@ impl Config { .model_reasoning_summary .or(cfg.model_reasoning_summary) .unwrap_or_default(), + model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, + model_reasoning_summary_format: cfg.model_reasoning_summary_format.clone(), model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), chatgpt_base_url: config_profile .chatgpt_base_url @@ -2953,7 +2950,6 @@ model_verbosity = "high" Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -2981,6 +2977,8 @@ model_verbosity = "high" show_raw_agent_reasoning: false, model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: ReasoningSummary::Detailed, + model_supports_reasoning_summaries: None, + model_reasoning_summary_format: None, model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, @@ -3027,7 +3025,6 @@ model_verbosity = "high" let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-3.5-turbo"), model_context_window: Some(16_385), model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), @@ -3055,6 +3052,8 @@ model_verbosity = "high" show_raw_agent_reasoning: false, model_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), + model_supports_reasoning_summaries: None, + model_reasoning_summary_format: None, model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, @@ -3116,7 +3115,6 @@ model_verbosity = "high" let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -3144,6 +3142,8 @@ model_verbosity = "high" show_raw_agent_reasoning: false, model_reasoning_effort: None, model_reasoning_summary: ReasoningSummary::default(), + model_supports_reasoning_summaries: None, + model_reasoning_summary_format: None, model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, @@ -3191,7 +3191,6 @@ model_verbosity = "high" let expected_gpt5_profile_config = Config { model: "gpt-5.1".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-5.1"), model_context_window: Some(272_000), model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), @@ -3219,6 +3218,8 @@ model_verbosity = "high" show_raw_agent_reasoning: false, model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: ReasoningSummary::Detailed, + model_supports_reasoning_summaries: None, + model_reasoning_summary_format: None, model_verbosity: Some(Verbosity::High), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 1580ab137c..9d89e14998 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -1,6 +1,7 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; +use crate::config::Config; use crate::config::types::ReasoningSummaryFormat; use crate::tools::handlers::apply_patch::ApplyPatchToolType; use crate::tools::spec::ConfigShellToolType; @@ -72,6 +73,18 @@ pub struct ModelFamily { pub truncation_policy: TruncationPolicy, } +impl ModelFamily { + pub fn with_config_overrides(mut self, config: &Config) -> Self { + if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { + self.supports_reasoning_summaries = supports_reasoning_summaries; + } + if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { + self.reasoning_summary_format = reasoning_summary_format.clone(); + } + self + } +} + macro_rules! model_family { ( $slug:expr, $family:expr $(, $key:ident : $value:expr )* $(,)? diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index c6f9365429..23124f10d5 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -2,10 +2,12 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelPreset; use tokio::sync::RwLock; +use crate::config::Config; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; +#[derive(Debug)] pub struct ModelsManager { pub available_models: RwLock>, pub etag: String, @@ -26,7 +28,7 @@ impl ModelsManager { *self.available_models.write().await = models; } - pub fn construct_model_family(&self, model: &str) -> ModelFamily { - find_family_for_model(model) + pub fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + find_family_for_model(model).with_config_overrides(config) } } diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 225825c93e..8a34a93328 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -10,6 +10,7 @@ use crate::client::ModelClient; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::config::Config; +use crate::openai_models::models_manager::ModelsManager; use crate::protocol::SandboxPolicy; use askama::Template; use codex_otel::otel_event_manager::OtelEventManager; @@ -46,6 +47,7 @@ pub(crate) async fn assess_command( auth_manager: Arc, parent_otel: &OtelEventManager, conversation_id: ConversationId, + models_manager: Arc, session_source: SessionSource, call_id: &str, command: &[String], @@ -124,12 +126,14 @@ pub(crate) async fn assess_command( output_schema: Some(sandbox_assessment_schema()), }; - let child_otel = - parent_otel.with_model(config.model.as_str(), config.model_family.slug.as_str()); + let model_family = models_manager.construct_model_family(&config.model, &config); + + let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); let client = ModelClient::new( Arc::clone(&config), Some(auth_manager), + model_family, child_otel, provider, Some(SANDBOX_ASSESSMENT_REASONING_EFFORT), diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 6e38ef6986..1c710ba108 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -26,10 +26,10 @@ impl TruncationPolicy { } } - pub fn new(config: &Config) -> Self { + pub fn new(config: &Config, truncation_policy: TruncationPolicy) -> Self { let config_token_limit = config.tool_output_token_limit; - match config.model_family.truncation_policy { + match truncation_policy { TruncationPolicy::Bytes(family_bytes) => { if let Some(token_limit) = config_token_limit { Self::Bytes(approx_bytes_for_tokens(token_limit)) diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index accac55e01..f24a9d6421 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -10,6 +10,7 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::openai_models::models_manager::ModelsManager; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemContent; @@ -70,14 +71,15 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + Some(AuthMode::ApiKey), false, "test".to_string(), ); @@ -85,6 +87,7 @@ async fn run_request(input: Vec) -> Value { let client = ModelClient::new( Arc::clone(&config), None, + model_family, otel_event_manager, provider, effort, diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 7b87163605..f50f3f2ca2 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,4 +1,5 @@ use assert_matches::assert_matches; +use codex_core::openai_models::models_manager::ModelsManager; use std::sync::Arc; use tracing_test::traced_test; @@ -70,14 +71,16 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let config = Arc::new(config); let conversation_id = ConversationId::new(); - + let auth_mode = AuthMode::ApiKey; + let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + Some(auth_mode), false, "test".to_string(), ); @@ -85,6 +88,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let client = ModelClient::new( Arc::clone(&config), None, + model_family, otel_event_manager, provider, effort, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index aff2ab60df..23bcadadf1 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -118,6 +118,7 @@ impl TestCodexBuilder { config, codex: new_conversation.conversation, session_configured: new_conversation.session_configured, + conversation_manager: Arc::new(conversation_manager), }) } @@ -160,6 +161,7 @@ pub struct TestCodex { pub codex: Arc, pub session_configured: SessionConfiguredEvent, pub config: Config, + pub conversation_manager: Arc, } impl TestCodex { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 7b6f645f2f..14264921fb 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -8,8 +8,11 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::config::types::ReasoningSummaryFormat; +use codex_core::openai_models::models_manager::ModelsManager; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SessionSource; use core_test_support::load_default_config_for_test; use core_test_support::responses; @@ -59,14 +62,16 @@ async fn responses_stream_includes_subagent_header_on_review() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - + let auth_mode = AuthMode::ChatGPT; + let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + Some(auth_mode), false, "test".to_string(), ); @@ -74,6 +79,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let client = ModelClient::new( Arc::clone(&config), None, + model_family, otel_event_manager, provider, effort, @@ -147,14 +153,17 @@ async fn responses_stream_includes_subagent_header_on_other() { let config = Arc::new(config); let conversation_id = ConversationId::new(); + let auth_mode = AuthMode::ChatGPT; + let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + Some(auth_mode), false, "test".to_string(), ); @@ -162,6 +171,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let client = ModelClient::new( Arc::clone(&config), None, + model_family, otel_event_manager, provider, effort, @@ -194,3 +204,109 @@ async fn responses_stream_includes_subagent_header_on_other() { Some("my-task") ); } + +#[tokio::test] +async fn responses_respects_model_family_overrides_from_config() { + core_test_support::skip_if_no_network!(); + + let server = responses::start_mock_server().await; + let response_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + + let request_recorder = responses::mount_sse_once(&server, response_body).await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + }; + + let codex_home = TempDir::new().expect("failed to create TempDir"); + let mut config = load_default_config_for_test(&codex_home); + config.model = "gpt-3.5-turbo".to_string(); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.model_supports_reasoning_summaries = Some(true); + config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); + config.model_reasoning_summary = ReasoningSummary::Detailed; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let conversation_id = ConversationId::new(); + let auth_mode = AuthMode::ChatGPT; + let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let model_family = models_manager.construct_model_family(&config.model, &config); + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + model_family.slug.as_str(), + None, + Some("test@test.com".to_string()), + Some(auth_mode), + false, + "test".to_string(), + ); + + let client = ModelClient::new( + Arc::clone(&config), + None, + model_family, + otel_event_manager, + provider, + effort, + summary, + conversation_id, + SessionSource::SubAgent(codex_protocol::protocol::SubAgentSource::Other( + "override-check".to_string(), + )), + ); + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".into(), + content: vec![ContentItem::InputText { + text: "hello".into(), + }], + }]; + + let mut stream = client.stream(&prompt).await.expect("stream failed"); + while let Some(event) = stream.next().await { + if matches!(event, Ok(ResponseEvent::Completed { .. })) { + break; + } + } + + let request = request_recorder.single_request(); + let body = request.body_json(); + let reasoning = body + .get("reasoning") + .and_then(|value| value.as_object()) + .cloned(); + + assert!( + reasoning.is_some(), + "reasoning should be present when config enables summaries" + ); + + assert_eq!( + reasoning + .as_ref() + .and_then(|value| value.get("summary")) + .and_then(|value| value.as_str()), + Some("detailed") + ); +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e5d3d7e61c..1170d13a90 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,7 +16,7 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::error::CodexErr; use codex_core::features::Feature; -use codex_core::openai_models::model_family::find_family_for_model; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; @@ -1017,11 +1017,13 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - + let auth_mode = AuthMode::ChatGPT; + let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), - config.model_family.slug.as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), @@ -1032,6 +1034,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let client = ModelClient::new( Arc::clone(&config), None, + model_family, otel_event_manager, provider, effort, @@ -1378,7 +1381,6 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.model = "gpt-5.1".to_string(); - config.model_family = find_family_for_model("gpt-5.1"); config.model_context_window = Some(272_000); }) .build(&server) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 219f29e2fa..95f2d35cd7 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,7 +1,6 @@ #![allow(clippy::unwrap_used)] use codex_core::features::Feature; -use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; @@ -73,7 +72,6 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); config.model = "codex-mini-latest".to_string(); - config.model_family = find_family_for_model("codex-mini-latest") }) .build(&server) .await?; @@ -125,13 +123,22 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + conversation_manager, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) .build(&server) .await?; - let base_instructions = config.model_family.base_instructions.clone(); + let base_instructions = conversation_manager + .get_models_manager() + .construct_model_family(&config.model, &config) + .base_instructions + .clone(); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 5dbdda4fcc..6969b6533d 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -2,9 +2,6 @@ #![allow(clippy::expect_used)] use anyhow::Result; -use codex_core::config::Config; -use codex_core::features::Feature; -use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::SandboxPolicy; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; @@ -18,6 +15,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::ApplyPatchModelOutput; use core_test_support::test_codex::ShellModelOutput; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -41,19 +39,6 @@ const FIXTURE_JSON: &str = r#"{ } "#; -fn configure_shell_command_model(output_type: ShellModelOutput, config: &mut Config) { - if !matches!(output_type, ShellModelOutput::ShellCommand) { - return; - } - - let shell_command_family = find_family_for_model("test-gpt-5-codex"); - if config.model_family.shell_type == shell_command_family.shell_type { - return; - } - config.model = shell_command_family.slug.clone(); - config.model_family = shell_command_family; -} - fn shell_responses( call_id: &str, command: Vec<&str>, @@ -113,6 +98,24 @@ fn shell_responses( } } +fn configure_shell_model( + builder: TestCodexBuilder, + output_type: ShellModelOutput, + include_apply_patch_tool: bool, +) -> TestCodexBuilder { + let builder = match (output_type, include_apply_patch_tool) { + (ShellModelOutput::ShellCommand, _) => builder.with_model("test-gpt-5-codex"), + (ShellModelOutput::LocalShell, true) => builder.with_model("gpt-5.1-codex"), + (ShellModelOutput::Shell, true) => builder.with_model("gpt-5.1-codex"), + (ShellModelOutput::LocalShell, false) => builder.with_model("codex-mini-latest"), + (ShellModelOutput::Shell, false) => builder.with_model("gpt-5"), + }; + + builder.with_config(move |config| { + config.include_apply_patch_tool = include_apply_patch_tool; + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::LocalShell)] @@ -122,10 +125,7 @@ async fn shell_output_stays_json_without_freeform_apply_patch( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5").with_config(move |config| { - config.features.disable(Feature::ApplyPatchFreeform); - configure_shell_command_model(output_type, config); - }); + let mut builder = configure_shell_model(test_codex(), output_type, false); let test = builder.build(&server).await?; let call_id = "shell-json"; @@ -177,10 +177,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::ApplyPatchFreeform); - configure_shell_command_model(output_type, config); - }); + let mut builder = configure_shell_model(test_codex(), output_type, true); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -225,10 +222,7 @@ async fn shell_output_preserves_fixture_json_without_serialization( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5").with_config(move |config| { - config.features.disable(Feature::ApplyPatchFreeform); - configure_shell_command_model(output_type, config); - }); + let mut builder = configure_shell_model(test_codex(), output_type, false); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); @@ -292,10 +286,7 @@ async fn shell_output_structures_fixture_with_serialization( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::ApplyPatchFreeform); - configure_shell_command_model(output_type, config); - }); + let mut builder = configure_shell_model(test_codex(), output_type, true); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); @@ -354,10 +345,7 @@ async fn shell_output_for_freeform_tool_records_duration( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - config.include_apply_patch_tool = true; - configure_shell_command_model(output_type, config); - }); + let mut builder = configure_shell_model(test_codex(), output_type, true); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -407,11 +395,9 @@ async fn shell_output_reserializes_truncated_content(output_type: ShellModelOutp skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex() - .with_model("gpt-5.1-codex") - .with_config(move |config| { + let mut builder = + configure_shell_model(test_codex(), output_type, true).with_config(move |config| { config.tool_output_token_limit = Some(200); - configure_shell_command_model(output_type, config); }); let test = builder.build(&server).await?; @@ -712,7 +698,6 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp .with_model("gpt-5.1-codex") .with_config(move |config| { config.include_apply_patch_tool = true; - configure_shell_command_model(output_type, config); }); let test = builder.build(&server).await?; @@ -748,7 +733,7 @@ async fn shell_command_output_is_freeform() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(move |config| { - configure_shell_command_model(ShellModelOutput::ShellCommand, config); + config.include_apply_patch_tool = true; }); let test = builder.build(&server).await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6120c7978d..28535e5366 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,7 +26,6 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::features::Feature; -use codex_core::openai_models::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::models_manager::ModelsManager; @@ -162,7 +161,6 @@ async fn handle_model_migration_prompt_if_needed( migration_config: migration_config_key.to_string(), }); config.model = target_model.to_string(); - config.model_family = find_family_for_model(&target_model); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -680,9 +678,7 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.config.model = model.clone(); - let family = find_family_for_model(&model); - self.config.model_family = family; + self.config.model = model; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8f9db3b9d9..8e07ce9be2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -465,10 +465,13 @@ impl ChatWidget { fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + let model_family = self + .models_manager + .construct_model_family(&self.config.model, &self.config); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), - &self.config, + &model_family, ); self.add_boxed_history(cell); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bdcaca7bea..1b8755efdf 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,6 +27,7 @@ use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::config::types::ReasoningSummaryFormat; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; @@ -1420,9 +1421,9 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, - config: &Config, + model_family: &ModelFamily, ) -> Box { - if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { + if model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { // Experimental format is following: // ** header ** // @@ -1517,12 +1518,15 @@ mod tests { use codex_core::config::ConfigToml; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; + use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::McpAuthStatus; + use codex_login::AuthMode; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; + use std::sync::Arc; use codex_core::protocol::ExecCommandSource; use mcp_types::CallToolResult; @@ -2320,12 +2324,12 @@ mod tests { } #[test] fn reasoning_summary_block() { - let mut config = test_config(); - config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - + let config = test_config(); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &config, + &model_family, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2337,24 +2341,47 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { - let mut config = test_config(); - config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - + let config = test_config(); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let cell = - new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config); + new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &model_family); let rendered = render_transcript(cell.as_ref()); assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); } #[test] - fn reasoning_summary_block_falls_back_when_header_is_missing() { + fn reasoning_summary_block_respects_config_overrides() { let mut config = test_config(); - config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; + config.model = "gpt-3.5-turbo".to_string(); + config.model_supports_reasoning_summaries = Some(true); + config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + + let model_family = models_manager.construct_model_family(&config.model, &config); + assert_eq!( + model_family.reasoning_summary_format, + ReasoningSummaryFormat::Experimental + ); + let cell = new_reasoning_summary_block( + "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), + &model_family, + ); + + let rendered_display = render_lines(&cell.display_lines(80)); + assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]); + } + + #[test] + fn reasoning_summary_block_falls_back_when_header_is_missing() { + let config = test_config(); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), - &config, + &model_family, ); let rendered = render_transcript(cell.as_ref()); @@ -2363,12 +2390,12 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { - let mut config = test_config(); - config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - + let config = test_config(); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), - &config, + &model_family, ); let rendered = render_transcript(cell.as_ref()); @@ -2376,7 +2403,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), - &config, + &model_family, ); let rendered = render_transcript(cell.as_ref()); @@ -2385,12 +2412,12 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { - let mut config = test_config(); - config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental; - + let config = test_config(); + let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), - &config, + &model_family, ); let rendered_display = render_lines(&cell.display_lines(80)); From 7dfc3a4dc7b20f046a21441d9bd32c9e88eea201 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:00:18 -0800 Subject: [PATCH 038/159] add --branch to codex cloud exec (#7602) Adds `--branch` to `codex cloud exec` to set base branch. --- codex-rs/cloud-tasks/src/cli.rs | 4 ++++ codex-rs/cloud-tasks/src/lib.rs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 4122aeff68..9c118038eb 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -28,6 +28,10 @@ pub struct ExecCommand { #[arg(long = "env", value_name = "ENV_ID")] pub environment: String, + /// Git branch to run in Codex Cloud. + #[arg(long = "branch", value_name = "BRANCH", default_value = "main")] + pub branch: String, + /// Number of assistant attempts (best-of-N). #[arg( long = "attempts", diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 6fc721404b..1a3798f758 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -101,6 +101,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { let crate::cli::ExecCommand { query, environment, + branch, attempts, } = args; let ctx = init_backend("codex_cloud_tasks_exec").await?; @@ -110,7 +111,7 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { &*ctx.backend, &env_id, &prompt, - "main", + &branch, false, attempts, ) From 6e6338aa876bb4258abe25b02ac6417b8ea9dff0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Dec 2025 12:17:54 -0800 Subject: [PATCH 039/159] Inline response recording and remove process_items indirection (#7310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inline response recording during streaming: `run_turn` now records items as they arrive instead of building a `ProcessedResponseItem` list and post‑processing via `process_items`. - Simplify turn handling: `handle_output_item_done` returns the follow‑up signal + optional tool future; `needs_follow_up` is set only there, and in‑flight tool futures are drained once at the end (errors logged, no extra state writes). - Flattened stream loop: removed `process_items` indirection and the extra output queue - - Tests: relaxed `tool_parallelism::tool_results_grouped` to allow any completion order while still requiring matching call/output IDs. --- codex-rs/core/src/codex.rs | 206 ++++++++-------------- codex-rs/core/src/error.rs | 9 +- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/response_processing.rs | 70 -------- codex-rs/core/src/stream_events_utils.rs | 212 +++++++++++++++++++++++ codex-rs/core/src/tools/parallel.rs | 1 + 6 files changed, 288 insertions(+), 212 deletions(-) delete mode 100644 codex-rs/core/src/response_processing.rs create mode 100644 codex-rs/core/src/stream_events_utils.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 94951c055b..abd5116f2a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -13,11 +13,12 @@ use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::features::Feature; use crate::features::Features; -use crate::function_tool::FunctionCallError; use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; -use crate::response_processing::process_items; +use crate::stream_events_utils::HandleOutputCtx; +use crate::stream_events_utils::handle_non_tool_response_item; +use crate::stream_events_utils::handle_output_item_done; use crate::terminal; use crate::truncate::TruncationPolicy; use crate::user_notification::UserNotifier; @@ -131,7 +132,6 @@ use codex_execpolicy::Policy as ExecPolicy; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; -use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -827,7 +827,7 @@ impl Session { } } - async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) { + pub(crate) async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) { self.send_event( turn_context, EventMsg::ItemStarted(ItemStartedEvent { @@ -839,7 +839,11 @@ impl Session { .await; } - async fn emit_turn_item_completed(&self, turn_context: &TurnContext, item: TurnItem) { + pub(crate) async fn emit_turn_item_completed( + &self, + turn_context: &TurnContext, + item: TurnItem, + ) { self.send_event( turn_context, EventMsg::ItemCompleted(ItemCompletedEvent { @@ -2060,15 +2064,16 @@ pub(crate) async fn run_task( .await { Ok(turn_output) => { - let processed_items = turn_output; + let TurnRunResult { + needs_follow_up, + last_agent_message: turn_last_agent_message, + } = turn_output; let limit = turn_context .client .get_auto_compact_token_limit() .unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= limit; - let (responses, items_to_record_in_conversation_history) = - process_items(processed_items, &sess, &turn_context).await; // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached { @@ -2081,10 +2086,8 @@ pub(crate) async fn run_task( continue; } - if responses.is_empty() { - last_agent_message = get_last_assistant_message_from_turn( - &items_to_record_in_conversation_history, - ); + if !needs_follow_up { + last_agent_message = turn_last_agent_message; sess.notifier() .notify(&UserNotification::AgentTurnComplete { thread_id: sess.conversation_id.to_string(), @@ -2097,10 +2100,7 @@ pub(crate) async fn run_task( } continue; } - Err(CodexErr::TurnAborted { - dangling_artifacts: processed_items, - }) => { - let _ = process_items(processed_items, &sess, &turn_context).await; + Err(CodexErr::TurnAborted) => { // Aborted turn is reported via a different event. break; } @@ -2130,7 +2130,7 @@ async fn run_turn( turn_diff_tracker: SharedTurnDiffTracker, input: Vec, cancellation_token: CancellationToken, -) -> CodexResult> { +) -> CodexResult { let mcp_tools = sess .services .mcp_connection_manager @@ -2184,13 +2184,10 @@ async fn run_turn( ) .await { + // todo(aibrahim): map special cases and ? on other errors Ok(output) => return Ok(output), - Err(CodexErr::TurnAborted { - dangling_artifacts: processed_items, - }) => { - return Err(CodexErr::TurnAborted { - dangling_artifacts: processed_items, - }); + Err(CodexErr::TurnAborted) => { + return Err(CodexErr::TurnAborted); } Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), @@ -2243,14 +2240,29 @@ async fn run_turn( } } -/// When the model is prompted, it returns a stream of events. Some of these -/// events map to a `ResponseItem`. A `ResponseItem` may need to be -/// "handled" such that it produces a `ResponseInputItem` that needs to be -/// sent back to the model on the next turn. #[derive(Debug)] -pub struct ProcessedResponseItem { - pub item: ResponseItem, - pub response: Option, +struct TurnRunResult { + needs_follow_up: bool, + last_agent_message: Option, +} + +async fn drain_in_flight( + in_flight: &mut FuturesOrdered>>, + sess: Arc, + turn_context: Arc, +) -> CodexResult<()> { + while let Some(res) = in_flight.next().await { + match res { + Ok(response_input) => { + sess.record_conversation_items(&turn_context, &[response_input.into()]) + .await; + } + Err(err) => { + error_or_panic(format!("in-flight tool future failed during drain: {err}")); + } + } + } + Ok(()) } #[allow(clippy::too_many_arguments)] @@ -2261,7 +2273,7 @@ async fn try_run_turn( turn_diff_tracker: SharedTurnDiffTracker, prompt: &Prompt, cancellation_token: CancellationToken, -) -> CodexResult> { +) -> CodexResult { let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -2285,114 +2297,47 @@ async fn try_run_turn( Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), ); - let mut output: FuturesOrdered>> = + let mut in_flight: FuturesOrdered>> = FuturesOrdered::new(); - + let mut needs_follow_up = false; + let mut last_agent_message: Option = None; let mut active_item: Option = None; - - loop { - // Poll the next item from the model stream. We must inspect *both* Ok and Err - // cases so that transient stream failures (e.g., dropped SSE connection before - // `response.completed`) bubble up and trigger the caller's retry logic. + let outcome: CodexResult = loop { let event = match stream.next().or_cancel(&cancellation_token).await { Ok(event) => event, - Err(codex_async_utils::CancelErr::Cancelled) => { - let processed_items = output.try_collect().await?; - return Err(CodexErr::TurnAborted { - dangling_artifacts: processed_items, - }); - } + Err(codex_async_utils::CancelErr::Cancelled) => break Err(CodexErr::TurnAborted), }; let event = match event { Some(res) => res?, None => { - return Err(CodexErr::Stream( + break Err(CodexErr::Stream( "stream closed before response.completed".into(), None, )); } }; - let add_completed = &mut |response_item: ProcessedResponseItem| { - output.push_back(future::ready(Ok(response_item)).boxed()); - }; - match event { ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { let previously_active_item = active_item.take(); - match ToolRouter::build_tool_call(sess.as_ref(), item.clone()).await { - Ok(Some(call)) => { - let payload_preview = call.payload.log_payload().into_owned(); - tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview); + let mut ctx = HandleOutputCtx { + sess: sess.clone(), + turn_context: turn_context.clone(), + tool_runtime: tool_runtime.clone(), + cancellation_token: cancellation_token.child_token(), + }; - let response = - tool_runtime.handle_tool_call(call, cancellation_token.child_token()); - - output.push_back( - async move { - Ok(ProcessedResponseItem { - item, - response: Some(response.await?), - }) - } - .boxed(), - ); - } - Ok(None) => { - if let Some(turn_item) = handle_non_tool_response_item(&item).await { - if previously_active_item.is_none() { - sess.emit_turn_item_started(&turn_context, &turn_item).await; - } - - sess.emit_turn_item_completed(&turn_context, turn_item) - .await; - } - - add_completed(ProcessedResponseItem { - item, - response: None, - }); - } - Err(FunctionCallError::MissingLocalShellCallId) => { - let msg = "LocalShellCall without call_id or id"; - turn_context - .client - .get_otel_event_manager() - .log_tool_failed("local_shell", msg); - error!(msg); - - let response = ResponseInputItem::FunctionCallOutput { - call_id: String::new(), - output: FunctionCallOutputPayload { - content: msg.to_string(), - ..Default::default() - }, - }; - add_completed(ProcessedResponseItem { - item, - response: Some(response), - }); - } - Err(FunctionCallError::RespondToModel(message)) - | Err(FunctionCallError::Denied(message)) => { - let response = ResponseInputItem::FunctionCallOutput { - call_id: String::new(), - output: FunctionCallOutputPayload { - content: message, - ..Default::default() - }, - }; - add_completed(ProcessedResponseItem { - item, - response: Some(response), - }); - } - Err(FunctionCallError::Fatal(message)) => { - return Err(CodexErr::Fatal(message)); - } + let output_result = + handle_output_item_done(&mut ctx, item, previously_active_item).await?; + if let Some(tool_future) = output_result.tool_future { + in_flight.push_back(tool_future); } + if let Some(agent_message) = output_result.last_agent_message { + last_agent_message = Some(agent_message); + } + needs_follow_up |= output_result.needs_follow_up; } ResponseEvent::OutputItemAdded(item) => { if let Some(turn_item) = handle_non_tool_response_item(&item).await { @@ -2413,7 +2358,6 @@ async fn try_run_turn( } => { sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; - let processed_items = output.try_collect().await?; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; tracker.get_unified_diff() @@ -2423,7 +2367,10 @@ async fn try_run_turn( sess.send_event(&turn_context, msg).await; } - return Ok(processed_items); + break Ok(TurnRunResult { + needs_follow_up, + last_agent_message, + }); } ResponseEvent::OutputTextDelta(delta) => { // In review child threads, suppress assistant text deltas; the @@ -2490,22 +2437,11 @@ async fn try_run_turn( } } } - } -} + }; -async fn handle_non_tool_response_item(item: &ResponseItem) -> Option { - debug!(?item, "Output item"); + drain_in_flight(&mut in_flight, sess, turn_context).await?; - match item { - ResponseItem::Message { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } => parse_turn_item(item), - ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { - debug!("unexpected tool output from stream"); - None - } - _ => None, - } + outcome } pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option { @@ -2540,8 +2476,10 @@ mod tests { use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::exec::ExecToolCallOutput; + use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; + use codex_protocol::models::FunctionCallOutputPayload; use crate::protocol::CompactedItem; use crate::protocol::CreditsSnapshot; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index a25261d649..8e9858dd11 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,4 +1,3 @@ -use crate::codex::ProcessedResponseItem; use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; @@ -61,9 +60,7 @@ pub enum SandboxErr { pub enum CodexErr { // todo(aibrahim): git rid of this error carrying the dangling artifacts #[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")] - TurnAborted { - dangling_artifacts: Vec, - }, + TurnAborted, /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. @@ -181,9 +178,7 @@ pub enum CodexErr { impl From for CodexErr { fn from(_: CancelErr) -> Self { - CodexErr::TurnAborted { - dangling_artifacts: Vec::new(), - } + CodexErr::TurnAborted } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 39dc224dd8..710b4c45b4 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -41,8 +41,8 @@ mod message_history; mod model_provider_info; pub mod parse_command; pub mod powershell; -mod response_processing; pub mod sandboxing; +mod stream_events_utils; mod text_encoding; pub mod token_data; mod truncate; diff --git a/codex-rs/core/src/response_processing.rs b/codex-rs/core/src/response_processing.rs deleted file mode 100644 index 458f82526a..0000000000 --- a/codex-rs/core/src/response_processing.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::codex::Session; -use crate::codex::TurnContext; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; -use codex_protocol::models::ResponseItem; -use tracing::warn; - -/// Process streamed `ResponseItem`s from the model into the pair of: -/// - items we should record in conversation history; and -/// - `ResponseInputItem`s to send back to the model on the next turn. -pub(crate) async fn process_items( - processed_items: Vec, - sess: &Session, - turn_context: &TurnContext, -) -> (Vec, Vec) { - let mut outputs_to_record = Vec::::new(); - let mut new_inputs_to_record = Vec::::new(); - let mut responses = Vec::::new(); - for processed_response_item in processed_items { - let crate::codex::ProcessedResponseItem { item, response } = processed_response_item; - - if let Some(response) = &response { - responses.push(response.clone()); - } - - match response { - Some(ResponseInputItem::FunctionCallOutput { call_id, output }) => { - new_inputs_to_record.push(ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }); - } - - Some(ResponseInputItem::CustomToolCallOutput { call_id, output }) => { - new_inputs_to_record.push(ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }); - } - Some(ResponseInputItem::McpToolCallOutput { call_id, result }) => { - let output = match result { - Ok(call_tool_result) => FunctionCallOutputPayload::from(&call_tool_result), - Err(err) => FunctionCallOutputPayload { - content: err.clone(), - success: Some(false), - ..Default::default() - }, - }; - new_inputs_to_record.push(ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output, - }); - } - None => {} - _ => { - warn!("Unexpected response item: {item:?} with response: {response:?}"); - } - }; - - outputs_to_record.push(item); - } - - let all_items_to_record = [outputs_to_record, new_inputs_to_record].concat(); - // Only attempt to take the lock if there is something to record. - if !all_items_to_record.is_empty() { - sess.record_conversation_items(turn_context, &all_items_to_record) - .await; - } - (responses, all_items_to_record) -} diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs new file mode 100644 index 0000000000..1cb74bc250 --- /dev/null +++ b/codex-rs/core/src/stream_events_utils.rs @@ -0,0 +1,212 @@ +use std::pin::Pin; +use std::sync::Arc; + +use codex_protocol::items::TurnItem; +use tokio_util::sync::CancellationToken; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::error::CodexErr; +use crate::error::Result; +use crate::function_tool::FunctionCallError; +use crate::parse_turn_item; +use crate::tools::parallel::ToolCallRuntime; +use crate::tools::router::ToolRouter; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use futures::Future; +use tracing::debug; + +/// Handle a completed output item from the model stream, recording it and +/// queuing any tool execution futures. This records items immediately so +/// history and rollout stay in sync even if the turn is later cancelled. +pub(crate) type InFlightFuture<'f> = + Pin> + Send + 'f>>; + +#[derive(Default)] +pub(crate) struct OutputItemResult { + pub last_agent_message: Option, + pub needs_follow_up: bool, + pub tool_future: Option>, +} + +pub(crate) struct HandleOutputCtx { + pub sess: Arc, + pub turn_context: Arc, + pub tool_runtime: ToolCallRuntime, + pub cancellation_token: CancellationToken, +} + +pub(crate) async fn handle_output_item_done( + ctx: &mut HandleOutputCtx, + item: ResponseItem, + previously_active_item: Option, +) -> Result { + let mut output = OutputItemResult::default(); + + match ToolRouter::build_tool_call(ctx.sess.as_ref(), item.clone()).await { + // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution. + Ok(Some(call)) => { + let payload_preview = call.payload.log_payload().into_owned(); + tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview); + + ctx.sess + .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) + .await; + + let cancellation_token = ctx.cancellation_token.child_token(); + let tool_runtime = ctx.tool_runtime.clone(); + + let tool_future: InFlightFuture<'static> = Box::pin(async move { + let response_input = tool_runtime + .handle_tool_call(call, cancellation_token) + .await?; + Ok(response_input) + }); + + output.needs_follow_up = true; + output.tool_future = Some(tool_future); + } + // No tool call: convert messages/reasoning into turn items and mark them as complete. + Ok(None) => { + if let Some(turn_item) = handle_non_tool_response_item(&item).await { + if previously_active_item.is_none() { + ctx.sess + .emit_turn_item_started(&ctx.turn_context, &turn_item) + .await; + } + + ctx.sess + .emit_turn_item_completed(&ctx.turn_context, turn_item) + .await; + } + + ctx.sess + .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) + .await; + let last_agent_message = last_assistant_message_from_item(&item); + + output.last_agent_message = last_agent_message; + } + // Guardrail: the model issued a LocalShellCall without an id; surface the error back into history. + Err(FunctionCallError::MissingLocalShellCallId) => { + let msg = "LocalShellCall without call_id or id"; + ctx.turn_context + .client + .get_otel_event_manager() + .log_tool_failed("local_shell", msg); + tracing::error!(msg); + + let response = ResponseInputItem::FunctionCallOutput { + call_id: String::new(), + output: FunctionCallOutputPayload { + content: msg.to_string(), + ..Default::default() + }, + }; + ctx.sess + .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) + .await; + if let Some(response_item) = response_input_to_response_item(&response) { + ctx.sess + .record_conversation_items( + &ctx.turn_context, + std::slice::from_ref(&response_item), + ) + .await; + } + + output.needs_follow_up = true; + } + // The tool request should be answered directly (or was denied); push that response into the transcript. + Err(FunctionCallError::RespondToModel(message)) + | Err(FunctionCallError::Denied(message)) => { + let response = ResponseInputItem::FunctionCallOutput { + call_id: String::new(), + output: FunctionCallOutputPayload { + content: message, + ..Default::default() + }, + }; + ctx.sess + .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item)) + .await; + if let Some(response_item) = response_input_to_response_item(&response) { + ctx.sess + .record_conversation_items( + &ctx.turn_context, + std::slice::from_ref(&response_item), + ) + .await; + } + + output.needs_follow_up = true; + } + // A fatal error occurred; surface it back into history. + Err(FunctionCallError::Fatal(message)) => { + return Err(CodexErr::Fatal(message)); + } + } + + Ok(output) +} + +pub(crate) async fn handle_non_tool_response_item(item: &ResponseItem) -> Option { + debug!(?item, "Output item"); + + match item { + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::WebSearchCall { .. } => parse_turn_item(item), + ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { + debug!("unexpected tool output from stream"); + None + } + _ => None, + } +} + +pub(crate) fn last_assistant_message_from_item(item: &ResponseItem) -> Option { + if let ResponseItem::Message { role, content, .. } = item + && role == "assistant" + { + return content.iter().rev().find_map(|ci| match ci { + codex_protocol::models::ContentItem::OutputText { text } => Some(text.clone()), + _ => None, + }); + } + None +} + +pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Option { + match input { + ResponseInputItem::FunctionCallOutput { call_id, output } => { + Some(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: output.clone(), + }) + } + ResponseInputItem::CustomToolCallOutput { call_id, output } => { + Some(ResponseItem::CustomToolCallOutput { + call_id: call_id.clone(), + output: output.clone(), + }) + } + ResponseInputItem::McpToolCallOutput { call_id, result } => { + let output = match result { + Ok(call_tool_result) => FunctionCallOutputPayload::from(call_tool_result), + Err(err) => FunctionCallOutputPayload { + content: err.clone(), + success: Some(false), + ..Default::default() + }, + }; + Some(ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output, + }) + } + _ => None, + } +} diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 33dc42b936..971ea934d8 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -17,6 +17,7 @@ use crate::tools::router::ToolRouter; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; +#[derive(Clone)] pub(crate) struct ToolCallRuntime { router: Arc, session: Arc, From 903b7774bc0e04c72ee8e61c6e12f7bdbbe7d267 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Dec 2025 12:57:54 -0800 Subject: [PATCH 040/159] Add models endpoint (#7603) - Use the codex-api crate to introduce models endpoint. - Add `models` to codex core tests helpers - Add `ModelsInfo` for the endpoint return type --- codex-rs/Cargo.lock | 2 + codex-rs/codex-api/Cargo.toml | 2 + codex-rs/codex-api/src/endpoint/mod.rs | 1 + codex-rs/codex-api/src/endpoint/models.rs | 216 ++++++++++++++++++ codex-rs/codex-api/src/lib.rs | 1 + .../codex-api/tests/models_integration.rs | 100 ++++++++ codex-rs/core/tests/common/responses.rs | 63 ++++- codex-rs/protocol/src/openai_models.rs | 74 ++++++ 8 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 codex-rs/codex-api/src/endpoint/models.rs create mode 100644 codex-rs/codex-api/tests/models_integration.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4429858c91..c3b6c27bee 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -858,6 +858,7 @@ dependencies = [ "http", "pretty_assertions", "regex-lite", + "reqwest", "serde", "serde_json", "thiserror 2.0.17", @@ -865,6 +866,7 @@ dependencies = [ "tokio-test", "tokio-util", "tracing", + "wiremock", ] [[package]] diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index f79416c96e..e9fc78878b 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -25,6 +25,8 @@ anyhow = { workspace = true } assert_matches = { workspace = true } pretty_assertions = { workspace = true } tokio-test = { workspace = true } +wiremock = { workspace = true } +reqwest = { workspace = true } [lints] workspace = true diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 104b4c2640..cb0eeb9f20 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,5 @@ pub mod chat; pub mod compact; +pub mod models; pub mod responses; mod streaming; diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs new file mode 100644 index 0000000000..fec8d7f292 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -0,0 +1,216 @@ +use crate::auth::AuthProvider; +use crate::auth::add_auth_headers; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::telemetry::run_with_request_telemetry; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use codex_protocol::openai_models::ModelsResponse; +use http::HeaderMap; +use http::Method; +use std::sync::Arc; + +pub struct ModelsClient { + transport: T, + provider: Provider, + auth: A, + request_telemetry: Option>, +} + +impl ModelsClient { + pub fn new(transport: T, provider: Provider, auth: A) -> Self { + Self { + transport, + provider, + auth, + request_telemetry: None, + } + } + + pub fn with_telemetry(mut self, request: Option>) -> Self { + self.request_telemetry = request; + self + } + + fn path(&self) -> &'static str { + "models" + } + + pub async fn list_models( + &self, + client_version: &str, + extra_headers: HeaderMap, + ) -> Result { + let builder = || { + let mut req = self.provider.build_request(Method::GET, self.path()); + req.headers.extend(extra_headers.clone()); + + let separator = if req.url.contains('?') { '&' } else { '?' }; + req.url = format!("{}{}client_version={client_version}", req.url, separator); + + add_auth_headers(&self.auth, req) + }; + + let resp = run_with_request_telemetry( + self.provider.retry.to_policy(), + self.request_telemetry.clone(), + builder, + |req| self.transport.execute(req), + ) + .await?; + + serde_json::from_slice::(&resp.body).map_err(|e| { + ApiError::Stream(format!( + "failed to decode models response: {e}; body: {}", + String::from_utf8_lossy(&resp.body) + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::RetryConfig; + use crate::provider::WireApi; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::HeaderMap; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct CapturingTransport { + last_request: Arc>>, + body: Arc, + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().unwrap() = Some(req); + let body = serde_json::to_vec(&*self.body).unwrap(); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: body.into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn bearer_token(&self) -> Option { + None + } + } + + fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + wire: WireApi::Responses, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + #[tokio::test] + async fn appends_client_version_query() { + let response = ModelsResponse { models: Vec::new() }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + }; + + let client = ModelsClient::new( + transport.clone(), + provider("https://example.com/api/codex"), + DummyAuth, + ); + + let result = client + .list_models("0.99.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(result.models.len(), 0); + + let url = transport + .last_request + .lock() + .unwrap() + .as_ref() + .unwrap() + .url + .clone(); + assert_eq!( + url, + "https://example.com/api/codex/models?client_version=0.99.0" + ); + } + + #[tokio::test] + async fn parses_models_response() { + let response = ModelsResponse { + models: vec![ + serde_json::from_value(json!({ + "slug": "gpt-test", + "display_name": "gpt-test", + "description": "desc", + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "high"], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": [0, 99, 0], + "supported_in_api": true, + "priority": 1 + })) + .unwrap(), + ], + }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + DummyAuth, + ); + + let result = client + .list_models("0.99.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(result.models.len(), 1); + assert_eq!(result.models[0].slug, "gpt-test"); + assert_eq!(result.models[0].supported_in_api, true); + assert_eq!(result.models[0].priority, 1); + } +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index acde4b4589..d0c382ac8c 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -22,6 +22,7 @@ pub use crate::common::create_text_param_for_request; pub use crate::endpoint::chat::AggregateStreamExt; pub use crate::endpoint::chat::ChatClient; pub use crate::endpoint::compact::CompactClient; +pub use crate::endpoint::models::ModelsClient; pub use crate::endpoint::responses::ResponsesClient; pub use crate::endpoint::responses::ResponsesOptions; pub use crate::error::ApiError; diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs new file mode 100644 index 0000000000..9994fe1d4e --- /dev/null +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -0,0 +1,100 @@ +use codex_api::AuthProvider; +use codex_api::ModelsClient; +use codex_api::provider::Provider; +use codex_api::provider::RetryConfig; +use codex_api::provider::WireApi; +use codex_client::ReqwestTransport; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningLevel; +use codex_protocol::openai_models::ShellType; +use http::HeaderMap; +use http::Method; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Default)] +struct DummyAuth; + +impl AuthProvider for DummyAuth { + fn bearer_token(&self) -> Option { + None + } +} + +fn provider(base_url: &str) -> Provider { + Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + wire: WireApi::Responses, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: std::time::Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: std::time::Duration::from_secs(1), + } +} + +#[tokio::test] +async fn models_client_hits_models_endpoint() { + let server = MockServer::start().await; + let base_url = format!("{}/api/codex", server.uri()); + + let response = ModelsResponse { + models: vec![ModelInfo { + slug: "gpt-test".to_string(), + display_name: "gpt-test".to_string(), + description: Some("desc".to_string()), + default_reasoning_level: ReasoningLevel::Medium, + supported_reasoning_levels: vec![ + ReasoningLevel::Low, + ReasoningLevel::Medium, + ReasoningLevel::High, + ], + shell_type: ShellType::ShellCommand, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + }], + }; + + Mock::given(method("GET")) + .and(path("/api/codex/models")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(&response), + ) + .mount(&server) + .await; + + let transport = ReqwestTransport::new(reqwest::Client::new()); + let client = ModelsClient::new(transport, provider(&base_url), DummyAuth); + + let result = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("models request should succeed"); + + assert_eq!(result.models.len(), 1); + assert_eq!(result.models[0].slug, "gpt-test"); + + let received = server + .received_requests() + .await + .expect("should capture requests"); + assert_eq!(received.len(), 1); + assert_eq!(received[0].method, Method::GET.as_str()); + assert_eq!(received[0].url.path(), "/api/codex/models"); +} diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index a8209b5139..e42b4ac943 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use anyhow::Result; use base64::Engine; +use codex_protocol::openai_models::ModelsResponse; use serde_json::Value; use wiremock::BodyPrintLimit; use wiremock::Match; @@ -193,6 +194,38 @@ impl ResponsesRequest { } } +#[derive(Debug, Clone)] +pub struct ModelsMock { + requests: Arc>>, +} + +impl ModelsMock { + fn new() -> Self { + Self { + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn single_request_path(&self) -> String { + let requests = self.requests.lock().unwrap(); + if requests.len() != 1 { + panic!("expected 1 request, got {}", requests.len()); + } + requests.first().unwrap().url.path().to_string() + } +} + +impl Match for ModelsMock { + fn matches(&self, request: &wiremock::Request) -> bool { + self.requests.lock().unwrap().push(request.clone()); + true + } +} + impl Match for ResponseMock { fn matches(&self, request: &wiremock::Request) -> bool { self.requests @@ -560,6 +593,14 @@ fn compact_mock() -> (MockBuilder, ResponseMock) { (mock, response_mock) } +fn models_mock() -> (MockBuilder, ModelsMock) { + let models_mock = ModelsMock::new(); + let mock = Mock::given(method("GET")) + .and(path_regex(".*/models$")) + .and(models_mock.clone()); + (mock, models_mock) +} + pub async fn mount_sse_once_match(server: &MockServer, matcher: M, body: String) -> ResponseMock where M: wiremock::Match + Send + Sync + 'static, @@ -616,11 +657,29 @@ pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Valu response_mock } +pub async fn mount_models_once(server: &MockServer, body: ModelsResponse) -> ModelsMock { + let (mock, models_mock) = models_mock(); + mock.respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(body.clone()), + ) + .up_to_n_times(1) + .mount(server) + .await; + models_mock +} + pub async fn start_mock_server() -> MockServer { - MockServer::builder() + let server = MockServer::builder() .body_print_limit(BodyPrintLimit::Limited(80_000)) .start() - .await + .await; + + // Provide a default `/models` response so tests remain hermetic when the client queries it. + let _ = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + server } #[derive(Clone)] diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index b99c3bbde1..92dcf3e4c2 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -73,3 +73,77 @@ pub struct ModelPreset { /// Whether this preset should appear in the picker UI. pub show_in_picker: bool, } + +/// Visibility of a model in the picker or APIs. +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ModelVisibility { + List, + Hide, + None, +} + +/// Reasoning support level reported by the backend. +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReasoningLevel { + None, + Minimal, + Low, + Medium, + High, + XHigh, +} + +/// Shell execution capability for a model. +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ShellType { + Default, + Local, + UnifiedExec, + Disabled, + ShellCommand, +} + +/// Semantic version triple encoded as an array in JSON (e.g. [0, 62, 0]). +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] +pub struct ClientVersion(pub i32, pub i32, pub i32); + +/// Model metadata returned by the Codex backend `/models` endpoint. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ModelInfo { + pub slug: String, + pub display_name: String, + #[serde(default)] + pub description: Option, + pub default_reasoning_level: ReasoningLevel, + pub supported_reasoning_levels: Vec, + pub shell_type: ShellType, + #[serde(default = "default_visibility")] + pub visibility: ModelVisibility, + pub minimal_client_version: ClientVersion, + #[serde(default)] + pub supported_in_api: bool, + #[serde(default)] + pub priority: i32, +} + +/// Response wrapper for `/models`. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] +pub struct ModelsResponse { + pub models: Vec, +} + +fn default_visibility() -> ModelVisibility { + ModelVisibility::None +} From 342c084cc3e9c0f8e83af9f55861f30af0b7e758 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 4 Dec 2025 13:45:07 -0800 Subject: [PATCH 041/159] fix(app-server): add duration_ms to McpToolCallItem (#7605) Seems like a nice field to have, and also VSCE does render this one. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 3 +++ codex-rs/app-server/src/bespoke_event_handling.rs | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 22d53c3dee..f650d4d1f0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1141,6 +1141,9 @@ pub enum ThreadItem { arguments: JsonValue, result: Option, error: Option, + /// The duration of the MCP tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8e39580fa8..547daf08b7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1178,6 +1178,7 @@ async fn construct_mcp_tool_call_notification( arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null), result: None, error: None, + duration_ms: None, }; ItemStartedNotification { thread_id, @@ -1197,6 +1198,7 @@ async fn construct_mcp_tool_call_end_notification( } else { McpToolCallStatus::Failed }; + let duration_ms = i64::try_from(end_event.duration.as_millis()).ok(); let (result, error) = match &end_event.result { Ok(value) => ( @@ -1222,6 +1224,7 @@ async fn construct_mcp_tool_call_end_notification( arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null), result, error, + duration_ms, }; ItemCompletedNotification { thread_id, @@ -1598,6 +1601,7 @@ mod tests { arguments: serde_json::json!({"server": ""}), result: None, error: None, + duration_ms: None, }, }; @@ -1751,6 +1755,7 @@ mod tests { arguments: JsonValue::Null, result: None, error: None, + duration_ms: None, }, }; @@ -1804,6 +1809,7 @@ mod tests { structured_content: None, }), error: None, + duration_ms: Some(0), }, }; @@ -1845,6 +1851,7 @@ mod tests { error: Some(McpToolCallError { message: "boom".to_string(), }), + duration_ms: Some(1), }, }; From e8f6d65899475858dd2935351082af74c9778195 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 4 Dec 2025 13:48:37 -0800 Subject: [PATCH 042/159] fix(app-server): add will_retry to ErrorNotification (#7611) VSCE renders `codex/event/stream_error` (automatically retried, e.g. `"Reconnecting... 1/n"`) and `codex/event/error` (terminal errors) differently, so add `will_retry` on ErrorNotification to indicate this. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 3 +++ codex-rs/app-server/src/bespoke_event_handling.rs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f650d4d1f0..cef25ca1b7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -942,6 +942,9 @@ pub struct TurnError { #[ts(export_to = "v2/")] pub struct ErrorNotification { pub error: TurnError, + // Set to true if the error is transient and the app-server process will automatically retry. + // If true, this will not interrupt a turn. + pub will_retry: bool, pub thread_id: String, pub turn_id: String, } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 547daf08b7..c9ecd01161 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -333,6 +333,7 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing .send_server_notification(ServerNotification::Error(ErrorNotification { error: turn_error, + will_retry: false, thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), })) @@ -348,6 +349,7 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing .send_server_notification(ServerNotification::Error(ErrorNotification { error: turn_error, + will_retry: true, thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), })) From 28dcdb566ad0052c616b40efda1ad852cbb04970 Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Fri, 5 Dec 2025 06:56:58 +0800 Subject: [PATCH 043/159] Fix `handle_shortcut_overlay_key` for cross-platform consistency (#7583) **Summary** - Shortcut toggle using `?` in `handle_shortcut_overlay_key` fails to trigger on some platforms (notably Windows). Current match requires `KeyCode::Char('?')` with `KeyModifiers::NONE`. Some terminals set `SHIFT` when producing `?` (since it is typically `Shift + /`), so the strict `NONE` check prevents toggling. **Impact** - On Windows consoles/terminals, pressing `?` with an empty composer often does nothing, leading to inconsistent UX compared to macOS/Linux. **Root Cause** - Crossterm/terminal backends report modifiers inconsistently across platforms. Generating `?` may include `SHIFT`. The code enforces `modifiers == NONE`, so valid `?` presses with `SHIFT` are ignored. AltGr keyboards may also surface as `ALT`. **Repro Steps** - Open the TUI, ensure the composer is empty. - Press `?`. - Expected: Shortcut overlay toggles. - Actual (Windows frequently): No toggle occurs. **Fix Options** - Option 1 (preferred): Accept `?` regardless of `SHIFT`, but reject `CONTROL` and `ALT`. - Rationale: Keeps behavior consistent across platforms with minimal code change. - Example change: - Before: matching `KeyModifiers::NONE` only. - After: allow `SHIFT`, disallow `CONTROL | ALT`. - Suggested condition: ```rust let toggles = matches!(key_event.code, KeyCode::Char('?')) && !key_event.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && self.is_empty(); ``` - Option 2: Platform-specific handling (Windows vs non-Windows). - Implement two variants or conditional branches using `#[cfg(target_os = "windows")]`. - On Windows, accept `?` with `SHIFT`; on other platforms, retain current behavior. - Trade-off: Higher maintenance burden and code divergence for limited benefit. --- close #5495 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4eeeb4bcee..4deb5125c1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1504,14 +1504,9 @@ impl ChatComposer { return false; } - let toggles = matches!( - key_event, - KeyEvent { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - .. - } if self.is_empty() - ); + let toggles = matches!(key_event.code, KeyCode::Char('?')) + && !has_ctrl_or_alt(key_event.modifiers) + && self.is_empty(); if !toggles { return false; From 0972cd940422828ac9cc9754d1ff07691ba07545 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Dec 2025 15:13:27 -0800 Subject: [PATCH 044/159] chore: refactor to move Arc concern outside exec_policy_for (#7615) The caller should decide whether wrapping the policy in `Arc` is necessary. This should make https://github.com/openai/codex/pull/7609 a bit smoother. - `exec_policy_for()` -> `load_exec_policy_for_features()` - introduce `load_exec_policy()` that does not take `Features` as an arg - both return `Result` instead of Result>, ExecPolicyError>` This simplifies the tests as they have no need for `Arc`. --- codex-rs/core/src/codex.rs | 4 +++- codex-rs/core/src/exec_policy.rs | 33 ++++++++++++++------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index abd5116f2a..436021a9d2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -11,6 +11,7 @@ use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; +use crate::exec_policy::load_exec_policy_for_features; use crate::features::Feature; use crate::features::Features; use crate::openai_models::models_manager::ModelsManager; @@ -174,9 +175,10 @@ impl Codex { let user_instructions = get_user_instructions(&config).await; - let exec_policy = crate::exec_policy::exec_policy_for(&config.features, &config.codex_home) + let exec_policy = load_exec_policy_for_features(&config.features, &config.codex_home) .await .map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?; + let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 1bbe60ff11..2d1c2efe5e 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -73,14 +73,18 @@ pub enum ExecPolicyUpdateError { FeatureDisabled, } -pub(crate) async fn exec_policy_for( +pub(crate) async fn load_exec_policy_for_features( features: &Features, codex_home: &Path, -) -> Result>, ExecPolicyError> { +) -> Result { if !features.enabled(Feature::ExecPolicy) { - return Ok(Arc::new(RwLock::new(Policy::empty()))); + Ok(Policy::empty()) + } else { + load_exec_policy(codex_home).await } +} +pub async fn load_exec_policy(codex_home: &Path) -> Result { let policy_dir = codex_home.join(POLICY_DIR_NAME); let policy_paths = collect_policy_files(&policy_dir).await?; @@ -102,7 +106,7 @@ pub(crate) async fn exec_policy_for( })?; } - let policy = Arc::new(RwLock::new(parser.build())); + let policy = parser.build(); tracing::debug!( "loaded execpolicy from {} files in {}", policy_paths.len(), @@ -306,7 +310,7 @@ mod tests { features.disable(Feature::ExecPolicy); let temp_dir = tempdir().expect("create temp dir"); - let policy = exec_policy_for(&features, temp_dir.path()) + let policy = load_exec_policy_for_features(&features, temp_dir.path()) .await .expect("policy result"); @@ -319,10 +323,7 @@ mod tests { decision: Decision::Allow }], }, - policy - .read() - .await - .check_multiple(commands.iter(), &|_| Decision::Allow) + policy.check_multiple(commands.iter(), &|_| Decision::Allow) ); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); } @@ -350,7 +351,7 @@ mod tests { ) .expect("write policy file"); - let policy = exec_policy_for(&Features::with_defaults(), temp_dir.path()) + let policy = load_exec_policy(temp_dir.path()) .await .expect("policy result"); let command = [vec!["rm".to_string()]]; @@ -362,10 +363,7 @@ mod tests { decision: Decision::Forbidden }], }, - policy - .read() - .await - .check_multiple(command.iter(), &|_| Decision::Allow) + policy.check_multiple(command.iter(), &|_| Decision::Allow) ); } @@ -378,7 +376,7 @@ mod tests { ) .expect("write policy file"); - let policy = exec_policy_for(&Features::with_defaults(), temp_dir.path()) + let policy = load_exec_policy(temp_dir.path()) .await .expect("policy result"); let command = [vec!["ls".to_string()]]; @@ -390,10 +388,7 @@ mod tests { decision: Decision::Allow }], }, - policy - .read() - .await - .check_multiple(command.iter(), &|_| Decision::Allow) + policy.check_multiple(command.iter(), &|_| Decision::Allow) ); } From 073a8533b83f10cf2c3aa3678ce11fd317587b50 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 4 Dec 2025 16:20:54 -0800 Subject: [PATCH 045/159] chore(apply-patch) scenarios for e2e testing (#7567) ## Summary This PR introduces an End to End test suite for apply-patch, so we can easily validate behavior against other implementations as well. ## Testing - [x] These are tests --- .../tests/fixtures/scenarios/.gitattributes | 1 + .../scenarios/001_add_file/expected/bar.md | 1 + .../fixtures/scenarios/001_add_file/patch.txt | 4 + .../expected/modify.txt | 2 + .../expected/nested/new.txt | 1 + .../002_multiple_operations/input/delete.txt | 1 + .../002_multiple_operations/input/modify.txt | 2 + .../002_multiple_operations/patch.txt | 9 ++ .../003_multiple_chunks/expected/multi.txt | 4 + .../003_multiple_chunks/input/multi.txt | 4 + .../scenarios/003_multiple_chunks/patch.txt | 9 ++ .../expected/old/other.txt | 1 + .../expected/renamed/dir/name.txt | 1 + .../input/old/name.txt | 1 + .../input/old/other.txt | 1 + .../004_move_to_new_directory/patch.txt | 7 ++ .../005_rejects_empty_patch/patch.txt | 2 + .../expected/modify.txt | 2 + .../input/modify.txt | 2 + .../006_rejects_missing_context/patch.txt | 6 + .../007_rejects_missing_file_delete/patch.txt | 3 + .../008_rejects_empty_update_hunk/patch.txt | 3 + .../patch.txt | 6 + .../expected/old/other.txt | 1 + .../expected/renamed/dir/name.txt | 1 + .../input/old/name.txt | 1 + .../input/old/other.txt | 1 + .../input/renamed/dir/name.txt | 1 + .../patch.txt | 7 ++ .../expected/duplicate.txt | 1 + .../input/duplicate.txt | 1 + .../patch.txt | 4 + .../012_delete_directory_fails/patch.txt | 3 + .../013_rejects_invalid_hunk_header/patch.txt | 3 + .../expected/no_newline.txt | 2 + .../input/no_newline.txt | 1 + .../patch.txt | 7 ++ .../expected/created.txt | 1 + .../patch.txt | 8 ++ .../expected/input.txt | 4 + .../input/input.txt | 2 + .../016_pure_addition_update_chunk/patch.txt | 6 + .../expected/foo.txt | 1 + .../input/foo.txt | 1 + .../patch.txt | 6 + .../expected/file.txt | 1 + .../input/file.txt | 1 + .../patch.txt | 6 + .../tests/fixtures/scenarios/README.md | 18 +++ codex-rs/apply-patch/tests/suite/mod.rs | 1 + codex-rs/apply-patch/tests/suite/scenarios.rs | 114 ++++++++++++++++++ 51 files changed, 277 insertions(+) create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt create mode 100644 codex-rs/apply-patch/tests/fixtures/scenarios/README.md create mode 100644 codex-rs/apply-patch/tests/suite/scenarios.rs diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes b/codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes new file mode 100644 index 0000000000..a42a20ddc5 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes @@ -0,0 +1 @@ +** text eol=lf diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md b/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md new file mode 100644 index 0000000000..6dfa057f0d --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md @@ -0,0 +1 @@ +This is a new file diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt new file mode 100644 index 0000000000..37735b2a46 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt @@ -0,0 +1,4 @@ +*** Begin Patch +*** Add File: bar.md ++This is a new file +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt new file mode 100644 index 0000000000..1b2ee3e566 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt @@ -0,0 +1,2 @@ +line1 +changed diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt new file mode 100644 index 0000000000..3151666398 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt @@ -0,0 +1 @@ +created diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt new file mode 100644 index 0000000000..6e263abce1 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt @@ -0,0 +1 @@ +obsolete diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt new file mode 100644 index 0000000000..c0d0fb45c3 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt new file mode 100644 index 0000000000..673dec2f78 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt @@ -0,0 +1,9 @@ +*** Begin Patch +*** Add File: nested/new.txt ++created +*** Delete File: delete.txt +*** Update File: modify.txt +@@ +-line2 ++changed +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt new file mode 100644 index 0000000000..9054a72916 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt @@ -0,0 +1,4 @@ +line1 +changed2 +line3 +changed4 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt new file mode 100644 index 0000000000..84275f9939 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt @@ -0,0 +1,4 @@ +line1 +line2 +line3 +line4 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt new file mode 100644 index 0000000000..45733c714b --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt @@ -0,0 +1,9 @@ +*** Begin Patch +*** Update File: multi.txt +@@ +-line2 ++changed2 +@@ +-line4 ++changed4 +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt new file mode 100644 index 0000000000..b61039d3df --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt @@ -0,0 +1 @@ +unrelated file diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt new file mode 100644 index 0000000000..b66ba06d31 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt @@ -0,0 +1 @@ +new content diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt new file mode 100644 index 0000000000..33194a0a6f --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt @@ -0,0 +1 @@ +old content diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt new file mode 100644 index 0000000000..b61039d3df --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt @@ -0,0 +1 @@ +unrelated file diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt new file mode 100644 index 0000000000..5e2d723a2b --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt @@ -0,0 +1,7 @@ +*** Begin Patch +*** Update File: old/name.txt +*** Move to: renamed/dir/name.txt +@@ +-old content ++new content +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt new file mode 100644 index 0000000000..4fcfecbbc7 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt @@ -0,0 +1,2 @@ +*** Begin Patch +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt new file mode 100644 index 0000000000..c0d0fb45c3 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt new file mode 100644 index 0000000000..c0d0fb45c3 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt new file mode 100644 index 0000000000..488438b12b --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt @@ -0,0 +1,6 @@ +*** Begin Patch +*** Update File: modify.txt +@@ +-missing ++changed +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt new file mode 100644 index 0000000000..6f95531db3 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt @@ -0,0 +1,3 @@ +*** Begin Patch +*** Delete File: missing.txt +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt new file mode 100644 index 0000000000..d7596a362b --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt @@ -0,0 +1,3 @@ +*** Begin Patch +*** Update File: foo.txt +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt new file mode 100644 index 0000000000..a7de4f24c5 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt @@ -0,0 +1,6 @@ +*** Begin Patch +*** Update File: missing.txt +@@ +-old ++new +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt new file mode 100644 index 0000000000..b61039d3df --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt @@ -0,0 +1 @@ +unrelated file diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt new file mode 100644 index 0000000000..3e757656cf --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt @@ -0,0 +1 @@ +new diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt new file mode 100644 index 0000000000..3940df7cd8 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt @@ -0,0 +1 @@ +from diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt new file mode 100644 index 0000000000..b61039d3df --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt @@ -0,0 +1 @@ +unrelated file diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt new file mode 100644 index 0000000000..cbaf024e5e --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt @@ -0,0 +1 @@ +existing diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt new file mode 100644 index 0000000000..c45ce6d782 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt @@ -0,0 +1,7 @@ +*** Begin Patch +*** Update File: old/name.txt +*** Move to: renamed/dir/name.txt +@@ +-from ++new +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt new file mode 100644 index 0000000000..b66ba06d31 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt @@ -0,0 +1 @@ +new content diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt new file mode 100644 index 0000000000..33194a0a6f --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt @@ -0,0 +1 @@ +old content diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt new file mode 100644 index 0000000000..bad9cf3fde --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt @@ -0,0 +1,4 @@ +*** Begin Patch +*** Add File: duplicate.txt ++new content +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt new file mode 100644 index 0000000000..a10bcd9ea9 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt @@ -0,0 +1,3 @@ +*** Begin Patch +*** Delete File: dir +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt new file mode 100644 index 0000000000..b35d7207d7 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt @@ -0,0 +1,3 @@ +*** Begin Patch +*** Frobnicate File: foo +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt new file mode 100644 index 0000000000..06fcdd77c9 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt @@ -0,0 +1,2 @@ +first line +second line diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt new file mode 100644 index 0000000000..a6e09874b5 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt @@ -0,0 +1 @@ +no newline at end diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt new file mode 100644 index 0000000000..4ed5818eb1 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt @@ -0,0 +1,7 @@ +*** Begin Patch +*** Update File: no_newline.txt +@@ +-no newline at end ++first line ++second line +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt @@ -0,0 +1 @@ +hello diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt new file mode 100644 index 0000000000..a6e9709d1f --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt @@ -0,0 +1,8 @@ +*** Begin Patch +*** Add File: created.txt ++hello +*** Update File: missing.txt +@@ +-old ++new +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt new file mode 100644 index 0000000000..f6d6f0bef8 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt @@ -0,0 +1,4 @@ +line1 +line2 +added line 1 +added line 2 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt new file mode 100644 index 0000000000..c0d0fb45c3 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt @@ -0,0 +1,2 @@ +line1 +line2 diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt new file mode 100644 index 0000000000..56337549f9 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt @@ -0,0 +1,6 @@ +*** Begin Patch +*** Update File: input.txt +@@ ++added line 1 ++added line 2 +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt new file mode 100644 index 0000000000..3e757656cf --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt @@ -0,0 +1 @@ +new diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt new file mode 100644 index 0000000000..3367afdbbf --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt @@ -0,0 +1 @@ +old diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt new file mode 100644 index 0000000000..21e6c1958d --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt @@ -0,0 +1,6 @@ +*** Begin Patch + *** Update File: foo.txt +@@ +-old ++new +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt new file mode 100644 index 0000000000..f719efd430 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt @@ -0,0 +1 @@ +two diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt new file mode 100644 index 0000000000..5626abf0f7 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt @@ -0,0 +1 @@ +one diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt new file mode 100644 index 0000000000..2648721797 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt @@ -0,0 +1,6 @@ + *** Begin Patch +*** Update File: file.txt +@@ +-one ++two +*** End Patch diff --git a/codex-rs/apply-patch/tests/fixtures/scenarios/README.md b/codex-rs/apply-patch/tests/fixtures/scenarios/README.md new file mode 100644 index 0000000000..65d1fbe2e4 --- /dev/null +++ b/codex-rs/apply-patch/tests/fixtures/scenarios/README.md @@ -0,0 +1,18 @@ +# Overview +This directory is a collection of end to end tests for the apply-patch specification, meant to be easily portable to other languages or platforms. + + +# Specification +Each test case is one directory, composed of input state (input/), the patch operation (patch.txt), and the expected final state (expected/). This structure is designed to keep tests simple (i.e. test exactly one patch at a time) while still providing enough flexibility to test any given operation across files. + +Here's what this would look like for a simple test apply-patch test case to create a new file: + +``` +001_add/ + input/ + foo.md + expected/ + foo.md + bar.md + patch.txt +``` diff --git a/codex-rs/apply-patch/tests/suite/mod.rs b/codex-rs/apply-patch/tests/suite/mod.rs index 882c5a6ffd..7d54de85ad 100644 --- a/codex-rs/apply-patch/tests/suite/mod.rs +++ b/codex-rs/apply-patch/tests/suite/mod.rs @@ -1,3 +1,4 @@ mod cli; +mod scenarios; #[cfg(not(target_os = "windows"))] mod tool; diff --git a/codex-rs/apply-patch/tests/suite/scenarios.rs b/codex-rs/apply-patch/tests/suite/scenarios.rs new file mode 100644 index 0000000000..4b3eb3c84a --- /dev/null +++ b/codex-rs/apply-patch/tests/suite/scenarios.rs @@ -0,0 +1,114 @@ +use assert_cmd::prelude::*; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use tempfile::tempdir; + +#[test] +fn test_apply_patch_scenarios() -> anyhow::Result<()> { + for scenario in fs::read_dir("tests/fixtures/scenarios")? { + let scenario = scenario?; + let path = scenario.path(); + if path.is_dir() { + run_apply_patch_scenario(&path)?; + } + } + Ok(()) +} + +/// Reads a scenario directory, copies the input files to a temporary directory, runs apply-patch, +/// and asserts that the final state matches the expected state exactly. +fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> { + let tmp = tempdir()?; + + // Copy the input files to the temporary directory + let input_dir = dir.join("input"); + if input_dir.is_dir() { + copy_dir_recursive(&input_dir, tmp.path())?; + } + + // Read the patch.txt file + let patch = fs::read_to_string(dir.join("patch.txt"))?; + + // Run apply_patch in the temporary directory. We intentionally do not assert + // on the exit status here; the scenarios are specified purely in terms of + // final filesystem state, which we compare below. + Command::cargo_bin("apply_patch")? + .arg(patch) + .current_dir(tmp.path()) + .output()?; + + // Assert that the final state matches the expected state exactly + let expected_dir = dir.join("expected"); + let expected_snapshot = snapshot_dir(&expected_dir)?; + let actual_snapshot = snapshot_dir(tmp.path())?; + + assert_eq!( + actual_snapshot, + expected_snapshot, + "Scenario {} did not match expected final state", + dir.display() + ); + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Entry { + File(Vec), + Dir, +} + +fn snapshot_dir(root: &Path) -> anyhow::Result> { + let mut entries = BTreeMap::new(); + if root.is_dir() { + snapshot_dir_recursive(root, root, &mut entries)?; + } + Ok(entries) +} + +fn snapshot_dir_recursive( + base: &Path, + dir: &Path, + entries: &mut BTreeMap, +) -> anyhow::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let Some(stripped) = path.strip_prefix(base).ok() else { + continue; + }; + let rel = stripped.to_path_buf(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + entries.insert(rel.clone(), Entry::Dir); + snapshot_dir_recursive(base, &path, entries)?; + } else if file_type.is_file() { + let contents = fs::read(&path)?; + entries.insert(rel, Entry::File(contents)); + } + } + Ok(()) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + let dest_path = dst.join(entry.file_name()); + if file_type.is_dir() { + fs::create_dir_all(&dest_path)?; + copy_dir_recursive(&path, &dest_path)?; + } else if file_type.is_file() { + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&path, &dest_path)?; + } + } + Ok(()) +} From 6736d1828deb4216376281e52333ac0ece9472ae Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 00:46:56 +0000 Subject: [PATCH 046/159] fix: sse for chat (#7594) --- codex-rs/codex-api/src/sse/chat.rs | 47 ++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index 7f50bb634a..5e48c57bd8 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -161,8 +161,10 @@ pub async fn process_chat_sse( } if let Some(func) = tool_call.get("function") { - if let Some(fname) = func.get("name").and_then(|n| n.as_str()) { - call_state.name = Some(fname.to_string()); + if let Some(fname) = func.get("name").and_then(|n| n.as_str()) + && !fname.is_empty() + { + call_state.name.get_or_insert_with(|| fname.to_string()); } if let Some(arguments) = func.get("arguments").and_then(|a| a.as_str()) { @@ -432,6 +434,47 @@ mod tests { ); } + #[tokio::test] + async fn preserves_tool_call_name_when_empty_deltas_arrive() { + let delta_with_name = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "id": "call_a", + "function": { "name": "do_a" } + }] + } + }] + }); + + let delta_with_empty_name = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "id": "call_a", + "function": { "name": "", "arguments": "{}" } + }] + } + }] + }); + + let finish = json!({ + "choices": [{ + "finish_reason": "tool_calls" + }] + }); + + let body = build_body(&[delta_with_name, delta_with_empty_name, finish]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, arguments, .. }), + ResponseEvent::Completed { .. } + ] if name == "do_a" && arguments == "{}" + ); + } + #[tokio::test] async fn emits_tool_calls_even_when_content_and_reasoning_present() { let delta_content_and_tools = json!({ From 7b359c9c8e455e5572c5a08c2488dfb5b028c329 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Dec 2025 18:28:03 -0800 Subject: [PATCH 047/159] Call models endpoint in models manager (#7616) - Introduce `with_remote_overrides` and update `refresh_available_models` - Put `auth_manager` instead of `auth_mode` on `models_manager` - Remove `ShellType` and `ReasoningLevel` to use already existing structs --- .../codex-api/tests/models_integration.rs | 14 +- codex-rs/core/src/codex.rs | 21 +-- codex-rs/core/src/conversation_manager.rs | 2 +- .../core/src/openai_models/model_family.rs | 80 ++++++++- .../core/src/openai_models/models_manager.rs | 154 +++++++++++++++++- codex-rs/core/src/tools/spec.rs | 17 +- .../core/tests/chat_completions_payload.rs | 5 +- codex-rs/core/tests/chat_completions_sse.rs | 10 +- codex-rs/core/tests/responses_headers.rs | 14 +- codex-rs/core/tests/suite/client.rs | 8 +- codex-rs/protocol/src/openai_models.rs | 61 ++++--- codex-rs/tui/src/chatwidget/tests.rs | 4 +- codex-rs/tui/src/history_cell.rs | 27 ++- 13 files changed, 329 insertions(+), 88 deletions(-) diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9994fe1d4e..3b4077f534 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -5,11 +5,11 @@ use codex_api::provider::RetryConfig; use codex_api::provider::WireApi; use codex_client::ReqwestTransport; use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; -use codex_protocol::openai_models::ReasoningLevel; -use codex_protocol::openai_models::ShellType; +use codex_protocol::openai_models::ReasoningEffort; use http::HeaderMap; use http::Method; use wiremock::Mock; @@ -55,13 +55,13 @@ async fn models_client_hits_models_endpoint() { slug: "gpt-test".to_string(), display_name: "gpt-test".to_string(), description: Some("desc".to_string()), - default_reasoning_level: ReasoningLevel::Medium, + default_reasoning_level: ReasoningEffort::Medium, supported_reasoning_levels: vec![ - ReasoningLevel::Low, - ReasoningLevel::Medium, - ReasoningLevel::High, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, ], - shell_type: ShellType::ShellCommand, + shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 436021a9d2..bff038cd6c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2475,6 +2475,7 @@ pub(crate) use tests::make_session_and_context_with_rx; #[cfg(test)] mod tests { use super::*; + use crate::CodexAuth; use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::exec::ExecToolCallOutput; @@ -2765,12 +2766,9 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); - let auth_manager = AuthManager::shared( - config.cwd.clone(), - false, - config.cli_auth_credentials_store_mode, - ); - let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &models_manager); @@ -2801,7 +2799,7 @@ mod tests { rollout: Mutex::new(None), user_shell: default_user_shell(), show_raw_agent_reasoning: config.show_raw_agent_reasoning, - auth_manager: Arc::clone(&auth_manager), + auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), models_manager: models_manager.clone(), tool_approvals: Mutex::new(ApprovalStore::default()), @@ -2847,12 +2845,9 @@ mod tests { .expect("load default test config"); let config = Arc::new(config); let conversation_id = ConversationId::default(); - let auth_manager = AuthManager::shared( - config.cwd.clone(), - false, - config.cli_auth_credentials_store_mode, - ); - let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &models_manager); diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 22f73dfe12..e527507c1c 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -47,7 +47,7 @@ impl ConversationManager { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager: auth_manager.clone(), session_source, - models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), + models_manager: Arc::new(ModelsManager::new(auth_manager)), } } diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 9d89e14998..6ee18ad9e3 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -1,11 +1,12 @@ use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use crate::config::Config; use crate::config::types::ReasoningSummaryFormat; use crate::tools::handlers::apply_patch::ApplyPatchToolType; -use crate::tools::spec::ConfigShellToolType; use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::ConfigShellToolType; /// The `instructions` field in the payload sent to a model should always start /// with this content. @@ -83,6 +84,15 @@ impl ModelFamily { } self } + pub fn with_remote_overrides(mut self, remote_models: Vec) -> Self { + for model in remote_models { + if model.slug == self.slug { + self.default_reasoning_effort = Some(model.default_reasoning_level); + self.shell_type = model.shell_type; + } + } + self + } } macro_rules! model_family { @@ -275,3 +285,71 @@ fn derive_default_model_family(model: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::openai_models::ClientVersion; + use codex_protocol::openai_models::ModelVisibility; + + fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo { + ModelInfo { + slug: slug.to_string(), + display_name: slug.to_string(), + description: Some(format!("{slug} desc")), + default_reasoning_level: effort, + supported_reasoning_levels: vec![effort], + shell_type: shell, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + } + } + + #[test] + fn remote_overrides_apply_when_slug_matches() { + let family = model_family!("gpt-4o-mini", "gpt-4o-mini"); + assert_ne!(family.default_reasoning_effort, Some(ReasoningEffort::High)); + + let updated = family.with_remote_overrides(vec![ + remote( + "gpt-4o-mini", + ReasoningEffort::High, + ConfigShellToolType::ShellCommand, + ), + remote( + "other-model", + ReasoningEffort::Low, + ConfigShellToolType::UnifiedExec, + ), + ]); + + assert_eq!( + updated.default_reasoning_effort, + Some(ReasoningEffort::High) + ); + assert_eq!(updated.shell_type, ConfigShellToolType::ShellCommand); + } + + #[test] + fn remote_overrides_skip_non_matching_models() { + let family = model_family!( + "codex-mini-latest", + "codex-mini-latest", + shell_type: ConfigShellToolType::Local + ); + + let updated = family.clone().with_remote_overrides(vec![remote( + "other", + ReasoningEffort::High, + ConfigShellToolType::ShellCommand, + )]); + + assert_eq!( + updated.default_reasoning_effort, + family.default_reasoning_effort + ); + assert_eq!(updated.shell_type, family.shell_type); + } +} diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 23124f10d5..fd0fea362d 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,34 +1,172 @@ -use codex_app_server_protocol::AuthMode; +use codex_api::ModelsClient; +use codex_api::ReqwestTransport; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; +use http::HeaderMap; +use std::sync::Arc; use tokio::sync::RwLock; +use crate::api_bridge::auth_provider_from_auth; +use crate::api_bridge::map_api_error; +use crate::auth::AuthManager; use crate::config::Config; +use crate::default_client::build_reqwest_client; +use crate::error::Result as CoreResult; +use crate::model_provider_info::ModelProviderInfo; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; #[derive(Debug)] pub struct ModelsManager { + // todo(aibrahim) merge available_models and model family creation into one struct pub available_models: RwLock>, + pub remote_models: RwLock>, pub etag: String, - pub auth_mode: Option, + pub auth_manager: Arc, } impl ModelsManager { - pub fn new(auth_mode: Option) -> Self { + pub fn new(auth_manager: Arc) -> Self { Self { - available_models: RwLock::new(builtin_model_presets(auth_mode)), + available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + remote_models: RwLock::new(Vec::new()), etag: String::new(), - auth_mode, + auth_manager, } } - pub async fn refresh_available_models(&self) { - let models = builtin_model_presets(self.auth_mode); - *self.available_models.write().await = models; + // do not use this function yet. It's work in progress. + pub async fn refresh_available_models( + &self, + provider: &ModelProviderInfo, + ) -> CoreResult> { + let auth = self.auth_manager.auth(); + let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; + let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; + let transport = ReqwestTransport::new(build_reqwest_client()); + let client = ModelsClient::new(transport, api_provider, api_auth); + + let response = client + .list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new()) + .await + .map_err(map_api_error)?; + + let models = response.models; + *self.remote_models.write().await = models.clone(); + { + let mut available_models_guard = self.available_models.write().await; + *available_models_guard = self.build_available_models().await; + } + Ok(models) } pub fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } + + async fn build_available_models(&self) -> Vec { + let mut available_models = self.remote_models.read().await.clone(); + available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); + let mut model_presets: Vec = + available_models.into_iter().map(Into::into).collect(); + if let Some(default) = model_presets.first_mut() { + default.is_default = true; + } + model_presets + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CodexAuth; + use crate::model_provider_info::WireApi; + use codex_protocol::openai_models::ModelsResponse; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { + serde_json::from_value(json!({ + "slug": slug, + "display_name": display, + "description": format!("{display} desc"), + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium"], + "shell_type": "shell_command", + "visibility": "list", + "minimal_client_version": [0, 1, 0], + "supported_in_api": true, + "priority": priority + })) + .expect("valid model") + } + + fn provider_for(base_url: String) -> ModelProviderInfo { + ModelProviderInfo { + name: "mock".into(), + base_url: Some(base_url), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + } + } + + #[tokio::test] + async fn refresh_available_models_sorts_and_marks_default() { + let server = MockServer::start().await; + let remote_models = vec![ + remote_model("priority-low", "Low", 1), + remote_model("priority-high", "High", 10), + ]; + let response = ModelsResponse { + models: remote_models.clone(), + }; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(&response), + ) + .expect(1) + .mount(&server) + .await; + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let manager = ModelsManager::new(auth_manager); + let provider = provider_for(server.uri()); + + let returned = manager + .refresh_available_models(&provider) + .await + .expect("refresh succeeds"); + + assert_eq!(returned, remote_models); + let cached_remote = manager.remote_models.read().await.clone(); + assert_eq!(cached_remote, remote_models); + + let available = manager.available_models.read().await.clone(); + assert_eq!(available.len(), 2); + assert_eq!(available[0].model, "priority-high"); + assert!( + available[0].is_default, + "highest priority should be default" + ); + assert_eq!(available[1].model, "priority-low"); + assert!(!available[1].is_default); + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2c3aa2d442..a36f54a6be 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::ApplyPatchToolType; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::registry::ToolRegistryBuilder; +use codex_protocol::openai_models::ConfigShellToolType; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -15,20 +16,6 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ConfigShellToolType { - Default, - Local, - UnifiedExec, - /// Do not include a shell tool by default. Useful when using Codex - /// with tools provided exclusively provided by MCP servers. Often used - /// with `--config base_instructions=CUSTOM_INSTRUCTIONS` - /// to customize agent behavior. - Disabled, - /// Takes a command as a single string to be run in the user's default shell. - ShellCommand, -} - #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, @@ -58,7 +45,7 @@ impl ToolsConfig { } else if features.enabled(Feature::UnifiedExec) { ConfigShellToolType::UnifiedExec } else { - model_family.shell_type.clone() + model_family.shell_type }; let apply_patch_tool_type = match model_family.apply_patch_tool_type { diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index f24a9d6421..db1407455a 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::LocalShellAction; use codex_core::LocalShellExecAction; @@ -71,7 +73,8 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index f50f3f2ca2..0351263ebb 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,9 +1,10 @@ use assert_matches::assert_matches; +use codex_core::AuthManager; use codex_core::openai_models::models_manager::ModelsManager; use std::sync::Arc; use tracing_test::traced_test; -use codex_app_server_protocol::AuthMode; +use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::ModelClient; use codex_core::ModelProviderInfo; @@ -71,8 +72,9 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_mode = AuthMode::ApiKey; - let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let auth_mode = auth_manager.get_auth_mode(); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -80,7 +82,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(auth_mode), + auth_mode, false, "test".to_string(), ); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 14264921fb..02423f3dfd 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::ModelClient; use codex_core::ModelProviderInfo; @@ -63,7 +65,8 @@ async fn responses_stream_includes_subagent_header_on_review() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -154,7 +157,8 @@ async fn responses_stream_includes_subagent_header_on_other() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( @@ -246,8 +250,8 @@ async fn responses_respects_model_family_overrides_from_config() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_mode = AuthMode::ChatGPT; - let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -255,7 +259,7 @@ async fn responses_respects_model_family_overrides_from_config() { model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(auth_mode), + auth_manager.get_auth_mode(), false, "test".to_string(), ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 1170d13a90..2df1f6db13 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,4 +1,4 @@ -use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::ConversationManager; @@ -1017,8 +1017,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_mode = AuthMode::ChatGPT; - let models_manager = Arc::new(ModelsManager::new(Some(auth_mode))); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -1026,7 +1026,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { model_family.slug.as_str(), None, Some("test@test.com".to_string()), - Some(AuthMode::ChatGPT), + auth_manager.get_auth_mode(), false, "test".to_string(), ); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 92dcf3e4c2..02d50627ca 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -86,28 +86,24 @@ pub enum ModelVisibility { None, } -/// Reasoning support level reported by the backend. -#[derive( - Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningLevel { - None, - Minimal, - Low, - Medium, - High, - XHigh, -} - /// Shell execution capability for a model. #[derive( - Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema, EnumIter, Display, + Debug, + Serialize, + Deserialize, + Clone, + Copy, + PartialEq, + Eq, + TS, + JsonSchema, + EnumIter, + Display, + Hash, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] -pub enum ShellType { +pub enum ConfigShellToolType { Default, Local, UnifiedExec, @@ -126,9 +122,9 @@ pub struct ModelInfo { pub display_name: String, #[serde(default)] pub description: Option, - pub default_reasoning_level: ReasoningLevel, - pub supported_reasoning_levels: Vec, - pub shell_type: ShellType, + pub default_reasoning_level: ReasoningEffort, + pub supported_reasoning_levels: Vec, + pub shell_type: ConfigShellToolType, #[serde(default = "default_visibility")] pub visibility: ModelVisibility, pub minimal_client_version: ClientVersion, @@ -147,3 +143,28 @@ pub struct ModelsResponse { fn default_visibility() -> ModelVisibility { ModelVisibility::None } + +// convert ModelInfo to ModelPreset +impl From for ModelPreset { + fn from(info: ModelInfo) -> Self { + ModelPreset { + id: info.slug.clone(), + model: info.slug, + display_name: info.display_name, + description: info.description.unwrap_or_default(), + default_reasoning_effort: info.default_reasoning_level, + supported_reasoning_efforts: info + .supported_reasoning_levels + .into_iter() + .map(|level| ReasoningEffortPreset { + effort: level, + // todo: add description for each reasoning effort + description: level.to_string(), + }) + .collect(), + is_default: false, // default is the highest priority available model + upgrade: None, // no upgrade available (todo: think about it) + show_in_picker: info.visibility == ModelVisibility::List, + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ef1de1fde3..eae5f50642 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -393,7 +393,7 @@ fn make_chatwidget_manual() -> ( active_cell: None, config: cfg.clone(), auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), + models_manager: Arc::new(ModelsManager::new(auth_manager)), session_header: SessionHeader::new(cfg.model), initial_user_message: None, token_info: None, @@ -431,7 +431,7 @@ fn make_chatwidget_manual() -> ( fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.get_auth_mode())); + chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.clone())); } pub(crate) fn make_chatwidget_manual_with_sender() -> ( diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 1b8755efdf..e70a31ffc0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1513,6 +1513,8 @@ mod tests { use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCall; use crate::exec_cell::ExecCell; + use codex_core::AuthManager; + use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -1520,7 +1522,6 @@ mod tests { use codex_core::config::types::McpServerTransportConfig; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::McpAuthStatus; - use codex_login::AuthMode; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -2325,7 +2326,9 @@ mod tests { #[test] fn reasoning_summary_block() { let config = test_config(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), @@ -2342,7 +2345,9 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let config = test_config(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &model_family); @@ -2357,7 +2362,9 @@ mod tests { config.model = "gpt-3.5-turbo".to_string(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); assert_eq!( @@ -2377,7 +2384,9 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { let config = test_config(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), @@ -2391,7 +2400,9 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { let config = test_config(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), @@ -2413,7 +2424,9 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { let config = test_config(); - let models_manager = Arc::new(ModelsManager::new(Some(AuthMode::ApiKey))); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = Arc::new(ModelsManager::new(auth_manager)); let model_family = models_manager.construct_model_family(&config.model, &config); let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), From 4c9762d15c66b489db2f114819d498b1f64e0530 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 21:48:15 -0800 Subject: [PATCH 048/159] fix typo (#7626) --- docs/execpolicy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/execpolicy.md b/docs/execpolicy.md index 48df2506f8..b5167a1eb4 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -1,6 +1,6 @@ # Execpolicy quickstart -Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in `.codexpolicy` files under `~/.codex/policy`. +Codex can enforce your own rules-based execution policy before it runs shell commands. Policies live in `.execpolicy` files under `~/.codex/policy`. ## How to create and edit rules From b1c918d8f72ee4baee15b16e02a8b36e3c36ea7f Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 21:55:54 -0800 Subject: [PATCH 049/159] feat: exec policy integration in shell mcp (#7609) adding execpolicy support into the `posix` mcp Co-authored-by: Michael Bolin --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/lib.rs | 3 + codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/posix.rs | 158 +++++++++++++++--- codex-rs/exec-server/src/posix/mcp.rs | 25 ++- .../src/posix/mcp_escalation_policy.rs | 24 +-- 6 files changed, 171 insertions(+), 41 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c3b6c27bee..48f87efc24 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1256,6 +1256,7 @@ dependencies = [ "async-trait", "clap", "codex-core", + "codex-execpolicy", "libc", "path-absolutize", "pretty_assertions", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 710b4c45b4..721c6bb43c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -97,7 +97,10 @@ mod user_shell_command; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; +pub use command_safety::is_dangerous_command; pub use command_safety::is_safe_command; +pub use exec_policy::ExecPolicyError; +pub use exec_policy::load_exec_policy; pub use safety::get_platform_sandbox; pub use safety::set_windows_sandbox_enabled; // Re-export the protocol types from the standalone `codex-protocol` crate so existing diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 5f8032595e..ab6ca80a12 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -24,6 +24,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-core = { workspace = true } +codex-execpolicy = { workspace = true } libc = { workspace = true } path-absolutize = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 3a4a4b9525..16da5885f5 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -57,8 +57,17 @@ //! use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; +use anyhow::Context as _; use clap::Parser; +use codex_core::config::find_codex_home; +use codex_core::is_dangerous_command::command_might_be_dangerous; +use codex_execpolicy::Decision; +use codex_execpolicy::Policy; +use codex_execpolicy::RuleMatch; +use rmcp::ErrorData as McpError; +use tokio::sync::RwLock; use tracing_subscriber::EnvFilter; use tracing_subscriber::{self}; @@ -87,6 +96,11 @@ struct McpServerCli { /// Path to Bash that has been patched to support execve() wrapping. #[arg(long = "bash")] bash_path: Option, + + /// Preserve program paths when applying execpolicy (e.g., keep /usr/bin/echo instead of echo). + /// Note: this does change the actual program being run. + #[arg(long)] + preserve_program_paths: bool, } #[tokio::main] @@ -113,13 +127,19 @@ pub async fn main_mcp_server() -> anyhow::Result<()> { Some(path) => path, None => mcp::get_bash_path()?, }; + let policy = Arc::new(RwLock::new(load_exec_policy().await?)); tracing::info!("Starting MCP server"); - let service = mcp::serve(bash_path, execve_wrapper, dummy_exec_policy) - .await - .inspect_err(|e| { - tracing::error!("serving error: {:?}", e); - })?; + let service = mcp::serve( + bash_path, + execve_wrapper, + policy, + cli.preserve_program_paths, + ) + .await + .inspect_err(|e| { + tracing::error!("serving error: {:?}", e); + })?; service.waiting().await?; Ok(()) @@ -146,26 +166,116 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> { std::process::exit(exit_code); } -// TODO: replace with execpolicy +/// Decide how to handle an exec() call for a specific command. +/// +/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec. +/// `argv` is the argv, including the program name (`argv[0]`). +pub(crate) fn evaluate_exec_policy( + policy: &Policy, + file: &Path, + argv: &[String], + preserve_program_paths: bool, +) -> Result { + let program_name = format_program_name(file, preserve_program_paths).ok_or_else(|| { + McpError::internal_error( + format!("failed to format program name for `{}`", file.display()), + None, + ) + })?; + let command: Vec = std::iter::once(program_name) + // Use the normalized program name instead of argv[0]. + .chain(argv.iter().skip(1).cloned()) + .collect(); + let evaluation = policy.check(&command, &|cmd| { + if command_might_be_dangerous(cmd) { + Decision::Prompt + } else { + Decision::Allow + } + }); -fn dummy_exec_policy(file: &Path, argv: &[String], _workdir: &Path) -> ExecPolicyOutcome { - if file.ends_with("rm") { - ExecPolicyOutcome::Forbidden - } else if file.ends_with("git") { - ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: false, - } - } else if file == Path::new("/opt/homebrew/bin/gh") - && let [_, arg1, arg2, ..] = argv - && arg1 == "issue" - && arg2 == "list" - { - ExecPolicyOutcome::Allow { - run_with_escalated_permissions: true, - } + // decisions driven by policy should run outside sandbox + let decision_driven_by_policy = evaluation.matched_rules.iter().any(|rule_match| { + !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == evaluation.decision + }); + + Ok(match evaluation.decision { + Decision::Forbidden => ExecPolicyOutcome::Forbidden, + Decision::Prompt => ExecPolicyOutcome::Prompt { + run_with_escalated_permissions: decision_driven_by_policy, + }, + Decision::Allow => ExecPolicyOutcome::Allow { + run_with_escalated_permissions: decision_driven_by_policy, + }, + }) +} + +fn format_program_name(path: &Path, preserve_program_paths: bool) -> Option { + if preserve_program_paths { + path.to_str().map(str::to_string) } else { - ExecPolicyOutcome::Allow { - run_with_escalated_permissions: false, - } + path.file_name()?.to_str().map(str::to_string) + } +} + +async fn load_exec_policy() -> anyhow::Result { + let codex_home = find_codex_home().context("failed to resolve codex_home for execpolicy")?; + codex_core::load_exec_policy(&codex_home) + .await + .map_err(anyhow::Error::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_execpolicy::Decision; + use codex_execpolicy::Policy; + use pretty_assertions::assert_eq; + use std::path::Path; + + #[test] + fn evaluate_exec_policy_uses_heuristics_for_dangerous_commands() { + let policy = Policy::empty(); + let file = Path::new("/bin/rm"); + let argv = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()]; + + let outcome = evaluate_exec_policy(&policy, file, &argv, false).expect("policy evaluation"); + + assert_eq!( + outcome, + ExecPolicyOutcome::Prompt { + run_with_escalated_permissions: false + } + ); + } + + #[test] + fn evaluate_exec_policy_respects_preserve_program_paths() { + let mut policy = Policy::empty(); + policy + .add_prefix_rule( + &[ + "/usr/local/bin/custom-cmd".to_string(), + "--flag".to_string(), + ], + Decision::Allow, + ) + .expect("policy rule should be added"); + let file = Path::new("/usr/local/bin/custom-cmd"); + let argv = vec![ + "/usr/local/bin/custom-cmd".to_string(), + "--flag".to_string(), + "value".to_string(), + ]; + + let outcome = evaluate_exec_policy(&policy, file, &argv, true).expect("policy evaluation"); + + assert_eq!( + outcome, + ExecPolicyOutcome::Allow { + run_with_escalated_permissions: true + } + ); } } diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index 134fdc01c0..bbbddc22e6 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -8,6 +8,7 @@ use codex_core::MCP_SANDBOX_STATE_CAPABILITY; use codex_core::MCP_SANDBOX_STATE_NOTIFICATION; use codex_core::SandboxState; use codex_core::protocol::SandboxPolicy; +use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; use rmcp::ServerHandler; @@ -27,7 +28,6 @@ use tracing::debug; use crate::posix::escalate_server::EscalateServer; use crate::posix::escalate_server::{self}; -use crate::posix::mcp_escalation_policy::ExecPolicy; use crate::posix::mcp_escalation_policy::McpEscalationPolicy; use crate::posix::stopwatch::Stopwatch; @@ -78,18 +78,25 @@ pub struct ExecTool { tool_router: ToolRouter, bash_path: PathBuf, execve_wrapper: PathBuf, - policy: ExecPolicy, + policy: Arc>, + preserve_program_paths: bool, sandbox_state: Arc>>, } #[tool_router] impl ExecTool { - pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: ExecPolicy) -> Self { + pub fn new( + bash_path: PathBuf, + execve_wrapper: PathBuf, + policy: Arc>, + preserve_program_paths: bool, + ) -> Self { Self { tool_router: Self::tool_router(), bash_path, execve_wrapper, policy, + preserve_program_paths, sandbox_state: Arc::new(RwLock::new(None)), } } @@ -121,7 +128,12 @@ impl ExecTool { let escalate_server = EscalateServer::new( self.bash_path.clone(), self.execve_wrapper.clone(), - McpEscalationPolicy::new(self.policy, context, stopwatch.clone()), + McpEscalationPolicy::new( + self.policy.clone(), + context, + stopwatch.clone(), + self.preserve_program_paths, + ), ); let result = escalate_server @@ -198,9 +210,10 @@ impl ServerHandler for ExecTool { pub(crate) async fn serve( bash_path: PathBuf, execve_wrapper: PathBuf, - policy: ExecPolicy, + policy: Arc>, + preserve_program_paths: bool, ) -> Result, rmcp::service::ServerInitializeError> { - let tool = ExecTool::new(bash_path, execve_wrapper, policy); + let tool = ExecTool::new(bash_path, execve_wrapper, policy, preserve_program_paths); tool.serve(stdio()).await } diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 9e059fdba5..97e76a6844 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; use rmcp::model::CreateElicitationRequestParam; @@ -11,15 +12,10 @@ use rmcp::service::RequestContext; use crate::posix::escalate_protocol::EscalateAction; use crate::posix::escalation_policy::EscalationPolicy; use crate::posix::stopwatch::Stopwatch; +use std::sync::Arc; +use tokio::sync::RwLock; -/// This is the policy which decides how to handle an exec() call. -/// -/// `file` is the absolute, canonical path to the executable to run, i.e. the first arg to exec. -/// `argv` is the argv, including the program name (`argv[0]`). -/// `workdir` is the absolute, canonical path to the working directory in which to execute the -/// command. -pub(crate) type ExecPolicy = fn(file: &Path, argv: &[String], workdir: &Path) -> ExecPolicyOutcome; - +#[derive(Debug, PartialEq, Eq)] pub(crate) enum ExecPolicyOutcome { Allow { run_with_escalated_permissions: bool, @@ -33,21 +29,25 @@ pub(crate) enum ExecPolicyOutcome { /// ExecPolicy with access to the MCP RequestContext so that it can leverage /// elicitations. pub(crate) struct McpEscalationPolicy { - policy: ExecPolicy, + /// In-memory execpolicy rules that drive how to handle an exec() call. + policy: Arc>, context: RequestContext, stopwatch: Stopwatch, + preserve_program_paths: bool, } impl McpEscalationPolicy { pub(crate) fn new( - policy: ExecPolicy, + policy: Arc>, context: RequestContext, stopwatch: Stopwatch, + preserve_program_paths: bool, ) -> Self { Self { policy, context, stopwatch, + preserve_program_paths, } } @@ -103,7 +103,9 @@ impl EscalationPolicy for McpEscalationPolicy { argv: &[String], workdir: &Path, ) -> Result { - let outcome = (self.policy)(file, argv, workdir); + let policy = self.policy.read().await; + let outcome = + crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { run_with_escalated_permissions, From b8eab7ce901762cafd11239c69b6fb58939afdbf Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 23:34:13 -0800 Subject: [PATCH 050/159] fix: taking plan type from usage endpoint instead of thru auth token (#7610) pull plan type from the usage endpoint, persist it in session state / tui state, and propagate through rate limit snapshots --- .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 7 +- .../app-server/tests/suite/v2/rate_limits.rs | 2 + codex-rs/backend-client/src/client.rs | 19 +++++ codex-rs/codex-api/src/rate_limits.rs | 1 + codex-rs/core/src/auth.rs | 25 ------- codex-rs/core/src/codex.rs | 74 +++++++++++++++++++ codex-rs/core/src/error.rs | 1 + codex-rs/core/src/state/session.rs | 9 ++- codex-rs/core/tests/suite/client.rs | 9 ++- codex-rs/protocol/src/protocol.rs | 1 + codex-rs/tui/src/chatwidget.rs | 7 ++ codex-rs/tui/src/chatwidget/tests.rs | 58 +++++++++++++++ codex-rs/tui/src/status/card.rs | 8 +- codex-rs/tui/src/status/helpers.rs | 10 ++- codex-rs/tui/src/status/tests.rs | 25 +++++++ 17 files changed, 224 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cef25ca1b7..35d7047661 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1524,6 +1524,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub plan_type: Option, } impl From for RateLimitSnapshot { @@ -1532,6 +1533,7 @@ impl From for RateLimitSnapshot { primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), + plan_type: value.plan_type, } } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c9ecd01161..94676999b5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1499,6 +1499,7 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), + plan_type: None, }; handle_token_count_event( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index b7f331c9d4..83ac26fd48 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -16,6 +16,9 @@ use tracing::warn; use crate::error_code::INTERNAL_ERROR_CODE; +#[cfg(test)] +use codex_protocol::account::PlanType; + /// Sends messages to the client and manages request callbacks. pub(crate) struct OutgoingMessageSender { next_request_id: AtomicI64, @@ -230,6 +233,7 @@ mod tests { }), secondary: None, credits: None, + plan_type: Some(PlanType::Plus), }, }); @@ -245,7 +249,8 @@ mod tests { "resetsAt": 123 }, "secondary": null, - "credits": null + "credits": null, + "planType": "plus" } }, }), diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 7ddccf7a74..e4e670310a 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; @@ -153,6 +154,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, + plan_type: Some(AccountPlanType::Pro), }, }; assert_eq!(received, expected); diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 0fb627ef0a..4b5eaa4105 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -7,6 +7,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_core::auth::CodexAuth; use codex_core::default_client::get_codex_user_agent; +use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; @@ -291,6 +292,7 @@ impl Client { primary, secondary, credits: Self::map_credits(payload.credits), + plan_type: Some(Self::map_plan_type(payload.plan_type)), } } @@ -325,6 +327,23 @@ impl Client { }) } + fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { + match plan_type { + crate::types::PlanType::Free => AccountPlanType::Free, + crate::types::PlanType::Plus => AccountPlanType::Plus, + crate::types::PlanType::Pro => AccountPlanType::Pro, + crate::types::PlanType::Team => AccountPlanType::Team, + crate::types::PlanType::Business => AccountPlanType::Business, + crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, + crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, + crate::types::PlanType::Guest + | crate::types::PlanType::Go + | crate::types::PlanType::FreeWorkspace + | crate::types::PlanType::Quorum + | crate::types::PlanType::K12 => AccountPlanType::Unknown, + } + } + fn window_minutes_from_seconds(seconds: i32) -> Option { if seconds <= 0 { return None; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 69092063f6..bb8ede2f57 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -37,6 +37,7 @@ pub fn parse_rate_limit(headers: &HeaderMap) -> Option { primary, secondary, credits, + plan_type: None, }) } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index a5c9add53f..72359ca4ca 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -227,23 +227,6 @@ impl CodexAuth { }) } - /// Raw plan string from the ID token (including unknown/new plan types). - pub fn raw_plan_type(&self) -> Option { - self.get_plan_type().map(|plan| match plan { - InternalPlanType::Known(k) => format!("{k:?}"), - InternalPlanType::Unknown(raw) => raw, - }) - } - - /// Raw internal plan value from the ID token. - /// Exposes the underlying `token_data::PlanType` without mapping it to the - /// public `AccountPlanType`. Use this when downstream code needs to inspect - /// internal/unknown plan strings exactly as issued in the token. - pub(crate) fn get_plan_type(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type) - } - fn get_current_auth_json(&self) -> Option { #[expect(clippy::unwrap_used)] self.auth_dot_json.lock().unwrap().clone() @@ -1041,10 +1024,6 @@ mod tests { .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); - pretty_assertions::assert_eq!( - auth.get_plan_type(), - Some(InternalPlanType::Known(InternalKnownPlan::Pro)) - ); } #[test] @@ -1065,10 +1044,6 @@ mod tests { .expect("auth available"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); - pretty_assertions::assert_eq!( - auth.get_plan_type(), - Some(InternalPlanType::Unknown("mystery-tier".to_string())) - ); } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bff038cd6c..d35f95e423 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2598,6 +2598,7 @@ mod tests { unlimited: false, balance: Some("10.00".to_string()), }), + plan_type: Some(codex_protocol::account::PlanType::Plus), }; state.set_rate_limits(initial.clone()); @@ -2613,6 +2614,7 @@ mod tests { resets_at: Some(1_900), }), credits: None, + plan_type: None, }; state.set_rate_limits(update.clone()); @@ -2622,6 +2624,78 @@ mod tests { primary: update.primary.clone(), secondary: update.secondary, credits: initial.credits, + plan_type: initial.plan_type, + }) + ); + } + + #[test] + fn set_rate_limits_updates_plan_type_when_present() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + let config = Arc::new(config); + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), + session_source: SessionSource::Exec, + }; + + let mut state = SessionState::new(session_configuration); + let initial = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 15.0, + window_minutes: Some(20), + resets_at: Some(1_600), + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(45), + resets_at: Some(1_650), + }), + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("15.00".to_string()), + }), + plan_type: Some(codex_protocol::account::PlanType::Plus), + }; + state.set_rate_limits(initial.clone()); + + let update = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 35.0, + window_minutes: Some(25), + resets_at: Some(1_700), + }), + secondary: None, + credits: None, + plan_type: Some(codex_protocol::account::PlanType::Pro), + }; + state.set_rate_limits(update.clone()); + + assert_eq!( + state.latest_rate_limits, + Some(RateLimitSnapshot { + primary: update.primary, + secondary: update.secondary, + credits: initial.credits, + plan_type: update.plan_type, }) ); } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 8e9858dd11..c7e0c2bde2 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -560,6 +560,7 @@ mod tests { resets_at: Some(secondary_reset_at), }), credits: None, + plan_type: None, } } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 8c739c9243..c61d188373 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -62,7 +62,7 @@ impl SessionState { } pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) { - self.latest_rate_limits = Some(merge_rate_limit_credits( + self.latest_rate_limits = Some(merge_rate_limit_fields( self.latest_rate_limits.as_ref(), snapshot, )); @@ -83,13 +83,16 @@ impl SessionState { } } -// Sometimes new snapshots don't include credits -fn merge_rate_limit_credits( +// Sometimes new snapshots don't include credits or plan information. +fn merge_rate_limit_fields( previous: Option<&RateLimitSnapshot>, mut snapshot: RateLimitSnapshot, ) -> RateLimitSnapshot { if snapshot.credits.is_none() { snapshot.credits = previous.and_then(|prior| prior.credits.clone()); } + if snapshot.plan_type.is_none() { + snapshot.plan_type = previous.and_then(|prior| prior.plan_type); + } snapshot } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2df1f6db13..a508ae6817 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1195,7 +1195,8 @@ async fn token_count_includes_rate_limits_snapshot() { "window_minutes": 60, "resets_at": 1704074400 }, - "credits": null + "credits": null, + "plan_type": null } }) ); @@ -1243,7 +1244,8 @@ async fn token_count_includes_rate_limits_snapshot() { "window_minutes": 60, "resets_at": 1704074400 }, - "credits": null + "credits": null, + "plan_type": null } }) ); @@ -1314,7 +1316,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "window_minutes": 60, "resets_at": null }, - "credits": null + "credits": null, + "plan_type": null }); let submission_id = codex diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4089c79373..225a622dcc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -847,6 +847,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub plan_type: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8e07ce9be2..e3b57ce9d1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -58,6 +58,7 @@ use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::UserInput; @@ -282,6 +283,7 @@ pub(crate) struct ChatWidget { initial_user_message: Option, token_info: Option, rate_limit_snapshot: Option, + plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, rate_limit_poller: Option>, @@ -580,6 +582,8 @@ impl ChatWidget { }); } + self.plan_type = snapshot.plan_type.or(self.plan_type); + let warnings = self.rate_limit_warnings.take_warnings( snapshot .secondary @@ -1275,6 +1279,7 @@ impl ChatWidget { ), token_info: None, rate_limit_snapshot: None, + plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, @@ -1357,6 +1362,7 @@ impl ChatWidget { ), token_info: None, rate_limit_snapshot: None, + plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, @@ -2001,6 +2007,7 @@ impl ChatWidget { context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), + self.plan_type, Local::now(), )); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index eae5f50642..6f2e656a5e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -47,6 +47,7 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -90,6 +91,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { }), secondary: None, credits: None, + plan_type: None, } } @@ -398,6 +400,7 @@ fn make_chatwidget_manual() -> ( initial_user_message: None, token_info: None, rate_limit_snapshot: None, + plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, @@ -546,6 +549,7 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { unlimited: false, balance: Some("17.5".to_string()), }), + plan_type: None, })); let initial_balance = chat .rate_limit_snapshot @@ -562,6 +566,7 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), secondary: None, credits: None, + plan_type: None, })); let display = chat @@ -581,6 +586,59 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { ); } +#[test] +fn rate_limit_snapshot_updates_and_retains_plan_type() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(300), + resets_at: None, + }), + credits: None, + plan_type: Some(PlanType::Plus), + })); + assert_eq!(chat.plan_type, Some(PlanType::Plus)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25.0, + window_minutes: Some(30), + resets_at: Some(123), + }), + secondary: Some(RateLimitWindow { + used_percent: 15.0, + window_minutes: Some(300), + resets_at: Some(234), + }), + credits: None, + plan_type: Some(PlanType::Pro), + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(60), + resets_at: Some(456), + }), + secondary: Some(RateLimitWindow { + used_percent: 18.0, + window_minutes: Some(300), + resets_at: Some(567), + }), + credits: None, + plan_type: None, + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); +} + #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { let (mut chat, _, _) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index d77a4d4946..797eded5fa 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -10,6 +10,7 @@ use codex_core::config::Config; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -65,6 +66,7 @@ struct StatusHistoryCell { rate_limits: StatusRateLimitData, } +#[allow(clippy::too_many_arguments)] pub(crate) fn new_status_output( config: &Config, auth_manager: &AuthManager, @@ -72,6 +74,7 @@ pub(crate) fn new_status_output( context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + plan_type: Option, now: DateTime, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); @@ -82,6 +85,7 @@ pub(crate) fn new_status_output( context_usage, session_id, rate_limits, + plan_type, now, ); @@ -89,6 +93,7 @@ pub(crate) fn new_status_output( } impl StatusHistoryCell { + #[allow(clippy::too_many_arguments)] fn new( config: &Config, auth_manager: &AuthManager, @@ -96,6 +101,7 @@ impl StatusHistoryCell { context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + plan_type: Option, now: DateTime, ) -> Self { let config_entries = create_config_summary_entries(config); @@ -111,7 +117,7 @@ impl StatusHistoryCell { SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), }; let agents_summary = compose_agents_summary(config); - let account = compose_account_display(auth_manager); + let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); let context_window = config.model_context_window.and_then(|window| { context_usage.map(|usage| StatusContextWindowData { diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index fcc2526541..cb6b7b54b2 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; use codex_core::config::Config; use codex_core::project_doc::discover_project_doc_paths; +use codex_protocol::account::PlanType; use std::path::Path; use unicode_width::UnicodeWidthStr; @@ -83,13 +84,18 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { } } -pub(crate) fn compose_account_display(auth_manager: &AuthManager) -> Option { +pub(crate) fn compose_account_display( + auth_manager: &AuthManager, + plan: Option, +) -> Option { let auth = auth_manager.auth()?; match auth.mode { AuthMode::ChatGPT => { let email = auth.get_account_email(); - let plan = auth.raw_plan_type().map(|plan| title_case(plan.as_str())); + let plan = plan + .map(|plan_type| title_case(format!("{plan_type:?}").as_str())) + .or_else(|| Some("Unknown".to_string())); Some(StatusAccountDisplay::ChatGpt { email, plan }) } AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 0709e366d0..35989883f1 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -120,6 +120,7 @@ fn status_snapshot_includes_reasoning_details() { resets_at: Some(reset_at_from(&captured_at, 1_200)), }), credits: None, + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -130,6 +131,7 @@ fn status_snapshot_includes_reasoning_details() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -171,6 +173,7 @@ fn status_snapshot_includes_monthly_limit() { }), secondary: None, credits: None, + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -181,6 +184,7 @@ fn status_snapshot_includes_monthly_limit() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -211,6 +215,7 @@ fn status_snapshot_shows_unlimited_credits() { unlimited: true, balance: None, }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output( @@ -220,6 +225,7 @@ fn status_snapshot_shows_unlimited_credits() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let rendered = render_lines(&composite.display_lines(120)); @@ -249,6 +255,7 @@ fn status_snapshot_shows_positive_credits() { unlimited: false, balance: Some("12.5".to_string()), }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output( @@ -258,6 +265,7 @@ fn status_snapshot_shows_positive_credits() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let rendered = render_lines(&composite.display_lines(120)); @@ -287,6 +295,7 @@ fn status_snapshot_hides_zero_credits() { unlimited: false, balance: Some("0".to_string()), }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output( @@ -296,6 +305,7 @@ fn status_snapshot_hides_zero_credits() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let rendered = render_lines(&composite.display_lines(120)); @@ -323,6 +333,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { unlimited: true, balance: None, }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output( @@ -332,6 +343,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let rendered = render_lines(&composite.display_lines(120)); @@ -369,6 +381,7 @@ fn status_card_token_usage_excludes_cached_tokens() { Some(&usage), &None, None, + None, now, ); let rendered = render_lines(&composite.display_lines(120)); @@ -410,6 +423,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { }), secondary: None, credits: None, + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -420,6 +434,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); @@ -461,6 +476,7 @@ fn status_snapshot_shows_missing_limits_message() { Some(&usage), &None, None, + None, now, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -509,6 +525,7 @@ fn status_snapshot_includes_credits_and_limits() { unlimited: false, balance: Some("37.5".to_string()), }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -519,6 +536,7 @@ fn status_snapshot_includes_credits_and_limits() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -551,6 +569,7 @@ fn status_snapshot_shows_empty_limits_message() { primary: None, secondary: None, credits: None, + plan_type: None, }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) @@ -565,6 +584,7 @@ fn status_snapshot_shows_empty_limits_message() { Some(&usage), &None, Some(&rate_display), + None, captured_at, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -609,6 +629,7 @@ fn status_snapshot_shows_stale_limits_message() { resets_at: Some(reset_at_from(&captured_at, 1_800)), }), credits: None, + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); @@ -620,6 +641,7 @@ fn status_snapshot_shows_stale_limits_message() { Some(&usage), &None, Some(&rate_display), + None, now, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -668,6 +690,7 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { unlimited: false, balance: Some("80".to_string()), }), + plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); @@ -679,6 +702,7 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&usage), &None, Some(&rate_display), + None, now, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); @@ -725,6 +749,7 @@ fn status_context_window_uses_last_usage() { Some(&last_usage), &None, None, + None, now, ); let rendered_lines = render_lines(&composite.display_lines(80)); From e91bb6b94781cbe1f22fe4b901ed4801d7931933 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 13:57:24 +0000 Subject: [PATCH 051/159] fix: ignore ghost snapshots in token consumption (#7638) --- codex-rs/core/src/context_manager/history.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b9a9c58f63..9b75c836ab 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -87,6 +87,7 @@ impl ContextManager { let items_tokens = self.items.iter().fold(0i64, |acc, item| { acc + match item { + ResponseItem::GhostSnapshot { .. } => 0, ResponseItem::Reasoning { encrypted_content: Some(content), .. From 5f80ad6da8946ea22b80e67b8283d32fe383eade Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:20:36 +0000 Subject: [PATCH 052/159] fix: chat completion with parallel tool call (#7634) --- codex-rs/codex-api/src/sse/chat.rs | 208 +++++++++++++++++++++++------ 1 file changed, 165 insertions(+), 43 deletions(-) diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index 5e48c57bd8..21adfa571a 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -10,6 +10,7 @@ use eventsource_stream::Eventsource; use futures::Stream; use futures::StreamExt; use std::collections::HashMap; +use std::collections::HashSet; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -41,12 +42,17 @@ pub async fn process_chat_sse( #[derive(Default, Debug)] struct ToolCallState { + id: Option, name: Option, arguments: String, } - let mut tool_calls: HashMap = HashMap::new(); - let mut tool_call_order: Vec = Vec::new(); + let mut tool_calls: HashMap = HashMap::new(); + let mut tool_call_order: Vec = Vec::new(); + let mut tool_call_order_seen: HashSet = HashSet::new(); + let mut tool_call_index_by_id: HashMap = HashMap::new(); + let mut next_tool_call_index = 0usize; + let mut last_tool_call_index: Option = None; let mut assistant_item: Option = None; let mut reasoning_item: Option = None; let mut completed_sent = false; @@ -149,15 +155,40 @@ pub async fn process_chat_sse( if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) { for tool_call in tool_call_values { - let id = tool_call - .get("id") - .and_then(|i| i.as_str()) - .map(str::to_string) - .unwrap_or_else(|| format!("tool-call-{}", tool_call_order.len())); + let mut index = tool_call + .get("index") + .and_then(serde_json::Value::as_u64) + .map(|i| i as usize); - let call_state = tool_calls.entry(id.clone()).or_default(); - if !tool_call_order.contains(&id) { - tool_call_order.push(id.clone()); + let mut call_id_for_lookup = None; + if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_id_for_lookup = Some(call_id.to_string()); + if let Some(existing) = tool_call_index_by_id.get(call_id) { + index = Some(*existing); + } + } + + if index.is_none() && call_id_for_lookup.is_none() { + index = last_tool_call_index; + } + + let index = index.unwrap_or_else(|| { + while tool_calls.contains_key(&next_tool_call_index) { + next_tool_call_index += 1; + } + let idx = next_tool_call_index; + next_tool_call_index += 1; + idx + }); + + let call_state = tool_calls.entry(index).or_default(); + if tool_call_order_seen.insert(index) { + tool_call_order.push(index); + } + + if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_state.id.get_or_insert_with(|| id.to_string()); + tool_call_index_by_id.entry(id.to_string()).or_insert(index); } if let Some(func) = tool_call.get("function") { @@ -171,6 +202,8 @@ pub async fn process_chat_sse( call_state.arguments.push_str(arguments); } } + + last_tool_call_index = Some(index); } } } @@ -224,13 +257,25 @@ pub async fn process_chat_sse( .await; } - for call_id in tool_call_order.drain(..) { - let state = tool_calls.remove(&call_id).unwrap_or_default(); + for index in tool_call_order.drain(..) { + let Some(state) = tool_calls.remove(&index) else { + continue; + }; + tool_call_order_seen.remove(&index); + let ToolCallState { + id, + name, + arguments, + } = state; + let Some(name) = name else { + debug!("Skipping tool call at index {index} because name is missing"); + continue; + }; let item = ResponseItem::FunctionCall { id: None, - name: state.name.unwrap_or_default(), - arguments: state.arguments, - call_id: call_id.clone(), + name, + arguments, + call_id: id.unwrap_or_else(|| format!("tool-call-{index}")), }; let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } @@ -335,6 +380,59 @@ mod tests { out } + #[tokio::test] + async fn concatenates_tool_call_arguments_across_deltas() { + let delta_name = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a" } + }] + } + }] + }); + + let delta_args_1 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "{ \"foo\":" } + }] + } + }] + }); + + let delta_args_2 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "1}" } + }] + } + }] + }); + + let finish = json!({ + "choices": [{ + "finish_reason": "tool_calls" + }] + }); + + let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }), + ResponseEvent::Completed { .. } + ] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}" + ); + } + #[tokio::test] async fn emits_multiple_tool_calls() { let delta_a = json!({ @@ -367,50 +465,74 @@ mod tests { let body = build_body(&[delta_a, delta_b, finish]); let events = collect_events(&body).await; - assert_eq!(events.len(), 3); - assert_matches!( - &events[0], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_a" && name == "do_a" && arguments == "{\"foo\":1}" + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}" ); - assert_matches!( - &events[1], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_b" && name == "do_b" && arguments == "{\"bar\":2}" - ); - assert_matches!(events[2], ResponseEvent::Completed { .. }); } #[tokio::test] - async fn concatenates_tool_call_arguments_across_deltas() { - let delta_name = json!({ + async fn emits_tool_calls_for_multiple_choices() { + let payload = json!({ + "choices": [ + { + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" + }, + { + "delta": { + "tool_calls": [{ + "id": "call_b", + "index": 0, + "function": { "name": "do_b", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" + } + ] + }); + + let body = build_body(&[payload]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}" + ); + } + + #[tokio::test] + async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() { + let delta_with_id = json!({ "choices": [{ "delta": { "tool_calls": [{ + "index": 0, "id": "call_a", - "function": { "name": "do_a" } + "function": { "name": "do_a", "arguments": "{ \"foo\":" } }] } }] }); - let delta_args_1 = json!({ + let delta_without_id = json!({ "choices": [{ "delta": { "tool_calls": [{ - "id": "call_a", - "function": { "arguments": "{ \"foo\":" } - }] - } - }] - }); - - let delta_args_2 = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", + "index": 0, "function": { "arguments": "1}" } }] } @@ -423,7 +545,7 @@ mod tests { }] }); - let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let body = build_body(&[delta_with_id, delta_without_id, finish]); let events = collect_events(&body).await; assert_matches!( &events[..], From d08efb1743a93fb52e0f2b4e2f060de7d89326ba Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 5 Dec 2025 10:40:15 -0800 Subject: [PATCH 053/159] Wire `with_remote_overrides` to construct model families (#7621) - This PR wires `with_remote_overrides` and make the `construct_model_families` an async function - Moves getting model family a level above to keep the function `sync` - Updates the tests to local, offline, and `sync` helper for model families --- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 92 +++++++++++-------- .../core/src/openai_models/models_manager.rs | 9 +- codex-rs/core/src/sandboxing/assessment.rs | 4 +- .../core/tests/chat_completions_payload.rs | 6 +- codex-rs/core/tests/chat_completions_sse.rs | 5 +- codex-rs/core/tests/common/Cargo.toml | 2 +- codex-rs/core/tests/responses_headers.rs | 16 ++-- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/prompt_caching.rs | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 21 ++++- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 21 +++-- codex-rs/tui/src/chatwidget/tests.rs | 4 + codex-rs/tui/src/history_cell.rs | 67 ++++++-------- 16 files changed, 147 insertions(+), 108 deletions(-) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8a329d0672..f24cc9bc67 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -90,6 +90,7 @@ wildmatch = { workspace = true } [features] deterministic_process_ids = [] +test-support = [] [target.'cfg(target_os = "linux")'.dependencies] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d35f95e423..89435ee6d0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::exec_policy::load_exec_policy_for_features; use crate::features::Feature; use crate::features::Features; +use crate::openai_models::model_family::ModelFamily; use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -398,35 +399,39 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { - fn make_turn_context( - auth_manager: Option>, - models_manager: Arc, - otel_event_manager: &OtelEventManager, - provider: ModelProviderInfo, - session_configuration: &SessionConfiguration, - conversation_id: ConversationId, - sub_id: String, - ) -> TurnContext { + fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { let config = session_configuration.original_config_do_not_use.clone(); - let features = &config.features; let mut per_turn_config = (*config).clone(); per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.features = features.clone(); - let model_family = - models_manager.construct_model_family(&per_turn_config.model, &per_turn_config); + per_turn_config.features = config.features.clone(); + per_turn_config + } + + #[allow(clippy::too_many_arguments)] + fn make_turn_context( + auth_manager: Option>, + otel_event_manager: &OtelEventManager, + provider: ModelProviderInfo, + session_configuration: &SessionConfiguration, + mut per_turn_config: Config, + model_family: ModelFamily, + conversation_id: ConversationId, + sub_id: String, + ) -> TurnContext { if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - session_configuration.model.as_str(), + model_family.slug.as_str(), ); + let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( - Arc::new(per_turn_config.clone()), + per_turn_config.clone(), auth_manager, model_family.clone(), otel_event_manager, @@ -439,7 +444,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features, + features: &per_turn_config.features, }); TurnContext { @@ -452,14 +457,14 @@ impl Session { user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), + shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, final_output_json_schema: None, - codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), exec_policy: session_configuration.exec_policy.clone(), truncation_policy: TruncationPolicy::new( - &per_turn_config, + per_turn_config.as_ref(), model_family.truncation_policy, ), } @@ -545,7 +550,9 @@ impl Session { }); } - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = models_manager + .construct_model_family(&config.model, &config) + .await; // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, @@ -768,12 +775,19 @@ impl Session { session_configuration }; + let per_turn_config = Self::build_per_turn_config(&session_configuration); + let model_family = self + .services + .models_manager + .construct_model_family(&per_turn_config.model, &per_turn_config) + .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), - Arc::clone(&self.services.models_manager), &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, self.conversation_id, sub_id, ); @@ -1907,7 +1921,8 @@ async fn spawn_review_thread( let review_model_family = sess .services .models_manager - .construct_model_family(&model, &config); + .construct_model_family(&model, &config) + .await; // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -2812,15 +2827,12 @@ mod tests { fn otel_event_manager( conversation_id: ConversationId, config: &Config, - models_manager: &ModelsManager, + model_family: &ModelFamily, ) -> OtelEventManager { OtelEventManager::new( conversation_id, config.model.as_str(), - models_manager - .construct_model_family(&config.model, config) - .slug - .as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), @@ -2843,9 +2855,6 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), @@ -2862,6 +2871,11 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = + ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2875,16 +2889,17 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), ); @@ -2922,9 +2937,6 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), @@ -2941,6 +2953,11 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = + ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2954,16 +2971,17 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), )); diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index fd0fea362d..22edf04ffe 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -61,7 +61,14 @@ impl ModelsManager { Ok(models) } - pub fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + find_family_for_model(model) + .with_config_overrides(config) + .with_remote_overrides(self.remote_models.read().await.clone()) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 8a34a93328..b7a9c952d1 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -126,7 +126,9 @@ pub(crate) async fn assess_command( output_schema: Some(sandbox_assessment_schema()), }; - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = models_manager + .construct_model_family(&config.model, &config) + .await; let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index db1407455a..1449a833da 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,8 +1,6 @@ use std::sync::Arc; use codex_app_server_protocol::AuthMode; -use codex_core::AuthManager; -use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::LocalShellAction; use codex_core::LocalShellExecAction; @@ -73,9 +71,7 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 0351263ebb..fe7ec58945 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,6 +1,5 @@ use assert_matches::assert_matches; use codex_core::AuthManager; -use codex_core::openai_models::models_manager::ModelsManager; use std::sync::Arc; use tracing_test::traced_test; @@ -12,6 +11,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::openai_models::models_manager::ModelsManager; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemContent; @@ -74,8 +74,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 75af1b4dd6..09da4bc701 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,7 +11,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } base64 = { workspace = true } -codex-core = { workspace = true } +codex-core = { workspace = true, features = ["test-support"] } codex-protocol = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 02423f3dfd..d79de72167 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -65,9 +65,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), @@ -157,9 +155,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, @@ -250,16 +246,16 @@ async fn responses_respects_model_family_overrides_from_config() { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); + let auth_mode = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), - auth_manager.get_auth_mode(), + auth_mode, false, "test".to_string(), ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a508ae6817..8b3d63a414 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1015,11 +1015,9 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; let config = Arc::new(config); - + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 95f2d35cd7..2bc71298d4 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -137,6 +137,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { let base_instructions = conversation_manager .get_models_manager() .construct_model_family(&config.model, &config) + .await .base_instructions .clone(); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 248205c427..4e5fad06b4 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -105,6 +105,7 @@ arboard = { workspace = true } [dev-dependencies] +codex-core = { workspace = true, features = ["test-support"] } assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 28535e5366..10d11d0535 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -302,7 +302,10 @@ impl App { }; let enhanced_keys_supported = tui.enhanced_keys_supported(); - + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(&config.model, &config) + .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -317,6 +320,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, + model_family, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -343,6 +347,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, + model_family, }; ChatWidget::new_from_existing( init, @@ -481,6 +486,11 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(&self.config.model, &self.config) + .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -500,6 +510,7 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, + model_family, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -549,6 +560,7 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new_from_existing( init, @@ -677,7 +689,12 @@ impl App { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); self.config.model = model; } AppEvent::OpenReasoningPopup { model } => { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2f59872bce..ca9de52e2a 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -340,6 +340,7 @@ impl App { let session_configured = new_conv.session_configured; let init = crate::chatwidget::ChatWidgetInit { config: cfg, + model_family: self.chat_widget.get_model_family(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3b57ce9d1..41fe181b33 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11,6 +11,7 @@ use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; @@ -261,6 +262,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) skills: Option>, pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, } #[derive(Default)] @@ -277,6 +279,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_cell: Option>, config: Config, + model_family: ModelFamily, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -465,15 +468,13 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - let model_family = self - .models_manager - .construct_model_family(&self.config.model, &self.config); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), - &model_family, + reasoning_summary_format, ); self.add_boxed_history(cell); } @@ -647,6 +648,9 @@ impl ChatWidget { self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } fn on_error(&mut self, message: String) { self.finalize_turn(); @@ -1249,6 +1253,7 @@ impl ChatWidget { feedback, skills, is_first_run, + model_family, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1270,6 +1275,7 @@ impl ChatWidget { }), active_cell: None, config: config.clone(), + model_family, auth_manager, models_manager, session_header: SessionHeader::new(config.model), @@ -1329,6 +1335,7 @@ impl ChatWidget { models_manager, feedback, skills, + model_family, .. } = common; let mut rng = rand::rng(); @@ -1353,6 +1360,7 @@ impl ChatWidget { }), active_cell: None, config: config.clone(), + model_family, auth_manager, models_manager, session_header: SessionHeader::new(config.model), @@ -1785,7 +1793,7 @@ impl ChatWidget { EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { self.on_agent_reasoning_delta(text); - self.on_agent_reasoning_final() + self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), @@ -2843,9 +2851,10 @@ impl ChatWidget { } /// Set the model in the widget's config copy. - pub(crate) fn set_model(&mut self, model: &str) { + pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); self.config.model = model.to_string(); + self.model_family = model_family; } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6f2e656a5e..229e075e7f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -345,6 +346,7 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); + let model_family = ModelsManager::construct_model_family_offline(&cfg.model, &cfg); let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( "test", ))); @@ -361,6 +363,7 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), skills: None, is_first_run: true, + model_family, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -394,6 +397,7 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_cell: None, config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&cfg.model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), session_header: SessionHeader::new(cfg.model), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e70a31ffc0..945ed1f491 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,7 +27,6 @@ use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::config::types::ReasoningSummaryFormat; -use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; @@ -1421,9 +1420,9 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, - model_family: &ModelFamily, + reasoning_summary_format: ReasoningSummaryFormat, ) -> Box { - if model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { + if reasoning_summary_format == ReasoningSummaryFormat::Experimental { // Experimental format is following: // ** header ** // @@ -1513,8 +1512,6 @@ mod tests { use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCall; use crate::exec_cell::ExecCell; - use codex_core::AuthManager; - use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -1527,7 +1524,6 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; - use std::sync::Arc; use codex_core::protocol::ExecCommandSource; use mcp_types::CallToolResult; @@ -2326,13 +2322,12 @@ mod tests { #[test] fn reasoning_summary_block() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2345,12 +2340,13 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); - let cell = - new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &model_family); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; + let cell = new_reasoning_summary_block( + "Detailed reasoning goes here.".to_string(), + reasoning_format, + ); let rendered = render_transcript(cell.as_ref()); assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); @@ -2362,11 +2358,7 @@ mod tests { config.model = "gpt-3.5-turbo".to_string(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2374,7 +2366,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + model_family.reasoning_summary_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2384,13 +2376,12 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2400,13 +2391,12 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), - &model_family, + reasoning_format.clone(), ); let rendered = render_transcript(cell.as_ref()); @@ -2414,7 +2404,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2424,13 +2414,12 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = + ModelsManager::construct_model_family_offline(&config.model, &config) + .reasoning_summary_format; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); From a8cbbdbc6ec485e933cec3da88820db65d1c568c Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 5 Dec 2025 11:03:25 -0800 Subject: [PATCH 054/159] feat(core) Add login to shell_command tool (#6846) ## Summary Adds the `login` parameter to the `shell_command` tool - optional, defaults to true. ## Testing - [x] Tested locally --- AGENTS.md | 1 + codex-rs/core/src/shell.rs | 42 +++++ codex-rs/core/src/tools/handlers/shell.rs | 48 +++++- codex-rs/core/src/tools/spec.rs | 9 ++ codex-rs/core/tests/common/lib.rs | 12 ++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/shell_command.rs | 174 +++++++++++++++++++++ codex-rs/protocol/src/models.rs | 3 + 8 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/tests/suite/shell_command.rs diff --git a/AGENTS.md b/AGENTS.md index aaebd0dfd3..f9f04c5b15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ If you don’t have the tool: ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. +- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields. ### Integration tests (core) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac115facb6..2338f41cd4 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -408,6 +408,48 @@ mod tests { } } + #[test] + fn derive_exec_args() { + let test_bash_shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + }; + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", false), + vec!["/bin/bash", "-c", "echo hello"] + ); + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", true), + vec!["/bin/bash", "-lc", "echo hello"] + ); + + let test_zsh_shell = Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from("/bin/zsh"), + }; + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", false), + vec!["/bin/zsh", "-c", "echo hello"] + ); + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", true), + vec!["/bin/zsh", "-lc", "echo hello"] + ); + + let test_powershell_shell = Shell { + shell_type: ShellType::PowerShell, + shell_path: PathBuf::from("pwsh.exe"), + }; + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", false), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"] + ); + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", true), + vec!["pwsh.exe", "-Command", "echo hello"] + ); + } + #[tokio::test] async fn test_current_shell_detects_zsh() { let shell = Command::new("sh") diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cd05d126bf..c3ef590e13 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -49,8 +49,7 @@ impl ShellCommandHandler { turn_context: &TurnContext, ) -> ExecParams { let shell = session.user_shell(); - let use_login_shell = true; - let command = shell.derive_exec_args(¶ms.command, use_login_shell); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); ExecParams { command, @@ -276,9 +275,15 @@ impl ShellHandler { mod tests { use std::path::PathBuf; + use codex_protocol::models::ShellCommandToolCallParams; + use pretty_assertions::assert_eq; + + use crate::codex::make_session_and_context; + use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; use crate::shell::Shell; use crate::shell::ShellType; + use crate::tools::handlers::ShellCommandHandler; /// The logic for is_known_safe_command() has heuristics for known shells, /// so we must ensure the commands generated by [ShellCommandHandler] can be @@ -312,4 +317,43 @@ mod tests { &shell.derive_exec_args(command, /* use_login_shell */ false) )); } + + #[test] + fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() { + let (session, turn_context) = make_session_and_context(); + + let command = "echo hello".to_string(); + let workdir = Some("subdir".to_string()); + let login = None; + let timeout_ms = Some(1234); + let with_escalated_permissions = Some(true); + let justification = Some("because tests".to_string()); + + let expected_command = session.user_shell().derive_exec_args(&command, true); + let expected_cwd = turn_context.resolve_path(workdir.clone()); + let expected_env = create_env(&turn_context.shell_environment_policy); + + let params = ShellCommandToolCallParams { + command, + workdir, + login, + timeout_ms, + with_escalated_permissions, + justification: justification.clone(), + }; + + let exec_params = ShellCommandHandler::to_exec_params(params, &session, &turn_context); + + // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. + assert_eq!(exec_params.command, expected_command); + assert_eq!(exec_params.cwd, expected_cwd); + assert_eq!(exec_params.env, expected_env); + assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); + assert_eq!( + exec_params.with_escalated_permissions, + with_escalated_permissions + ); + assert_eq!(exec_params.justification, justification); + assert_eq!(exec_params.arg0, None); + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a36f54a6be..e72becd882 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -331,6 +331,15 @@ fn create_shell_command_tool() -> ToolSpec { description: Some("The working directory to execute the command in".to_string()), }, ); + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with login shell semantics. Defaults to true." + .to_string(), + ), + }, + ); properties.insert( "timeout_ms".to_string(), JsonSchema::Number { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index e7b1e71efa..2c8c28d712 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -369,3 +369,15 @@ macro_rules! skip_if_no_network { } }}; } + +#[macro_export] +macro_rules! skip_if_windows { + ($return_value:expr $(,)?) => {{ + if cfg!(target_os = "windows") { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return $return_value; + } + }}; +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 86f417801a..e2d78004a5 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -46,6 +46,7 @@ mod review; mod rmcp_client; mod rollout_list_find; mod seatbelt; +mod shell_command; mod shell_serialization; mod stream_error_allows_next_turn; mod stream_no_completed; diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs new file mode 100644 index 0000000000..10e972b3de --- /dev/null +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use core_test_support::assert_regex_match; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_windows; +use core_test_support::test_codex::TestCodexBuilder; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use serde_json::json; + +fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec { + let args = json!({ + "command": command, + "timeout_ms": 2_000, + "login": login, + }); + + #[allow(clippy::expect_used)] + let arguments = serde_json::to_string(&args).expect("serialize shell command arguments"); + + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ] +} + +async fn shell_command_harness_with( + configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, +) -> Result { + let builder = configure(test_codex()).with_config(|config| { + config.include_apply_patch_tool = true; + }); + TestCodexHarness::with_builder(builder).await +} + +async fn mount_shell_responses( + harness: &TestCodexHarness, + call_id: &str, + command: &str, + login: Option, +) { + mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await; +} + +fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> { + let normalized_output = output + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim_end_matches('\n') + .to_string(); + + let expected_pattern = format!( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{expected}\n?$" + ); + + assert_regex_match(&expected_pattern, &normalized_output); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_works() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", None).await; + harness.submit("run the echo command").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-true"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(true)).await; + harness.submit("run the echo command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(false)).await; + harness.submit("run the echo command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_line_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-first-extra-login"; + mount_shell_responses( + &harness, + call_id, + "echo 'first line\nsecond line'", + Some(true), + ) + .await; + harness.submit("run the command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "first line\nsecond line")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-second-extra-no-login"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", None).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-third-extra-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", Some(false)).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index daf98152d5..f93c157b7c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -348,6 +348,9 @@ pub struct ShellCommandToolCallParams { pub command: String, pub workdir: Option, + /// Whether to run the shell with login shell semantics + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, From f48d88067efae4a1cf5df6d7ec5210c728afa4e1 Mon Sep 17 00:00:00 2001 From: Pavel Krymets Date: Fri, 5 Dec 2025 12:09:43 -0800 Subject: [PATCH 055/159] Fix unified_exec on windows (#7620) Fix unified_exec on windows Requires removal of PSUEDOCONSOLE_INHERIT_CURSOR flag so child processed don't attempt to wait for cursor position response (and timeout). https://github.com/wezterm/wezterm/compare/main...pakrym:wezterm:PSUEDOCONSOLE_INHERIT_CURSOR?expand=1 --------- Co-authored-by: pakrym-oai --- codex-rs/Cargo.lock | 8 +- codex-rs/Cargo.toml | 3 +- codex-rs/core/tests/common/lib.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 92 ++++++++++++++++++++++- codex-rs/utils/pty/Cargo.toml | 2 +- codex-rs/utils/pty/src/lib.rs | 30 ++++++++ 6 files changed, 128 insertions(+), 11 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48f87efc24..d809adfad1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2557,8 +2557,7 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" dependencies = [ "libc", "thiserror 1.0.69", @@ -4632,8 +4631,7 @@ dependencies = [ [[package]] name = "portable-pty" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -4642,7 +4640,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.28.0", + "nix 0.29.0", "serial2", "shared_library", "shell-words", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2339cd4e67..d3d3c36c3e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -178,8 +178,8 @@ seccompiler = "0.5.0" sentry = "0.34.0" serde = "1" serde_json = "1" -serde_yaml = "0.9" serde_with = "3.16" +serde_yaml = "0.9" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" @@ -288,6 +288,7 @@ opt-level = 0 # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } +portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } # Uncomment to debug local changes. diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 2c8c28d712..d643fb77fc 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -374,9 +374,7 @@ macro_rules! skip_if_no_network { macro_rules! skip_if_windows { ($return_value:expr $(,)?) => {{ if cfg!(target_os = "windows") { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); + println!("Skipping test because it cannot execute on Windows."); return $return_value; } }}; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5e8f5a8cdf..33e469fc1a 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,4 +1,3 @@ -#![cfg(not(target_os = "windows"))] use std::collections::HashMap; use std::ffi::OsStr; use std::fs; @@ -24,6 +23,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; @@ -155,6 +155,7 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let builder = test_codex().with_config(|config| { config.include_apply_patch_tool = true; @@ -279,6 +280,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -350,6 +352,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { async fn unified_exec_resolves_relative_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -427,6 +430,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { async fn unified_exec_respects_workdir_override() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -505,6 +509,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { async fn unified_exec_emits_exec_command_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -591,6 +596,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -662,6 +668,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -761,6 +768,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -857,6 +865,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -978,6 +987,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1085,6 +1095,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { async fn unified_exec_respects_early_exit_notifications() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1177,6 +1188,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1338,6 +1350,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1442,6 +1455,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() async fn unified_exec_reuses_session_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1553,6 +1567,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { async fn unified_exec_streams_after_lagged_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1684,6 +1699,7 @@ PY async fn unified_exec_timeout_and_followup_poll() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1790,6 +1806,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { async fn unified_exec_formats_large_output_summary() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1875,6 +1892,7 @@ PY async fn unified_exec_runs_under_sandbox() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -2067,11 +2085,83 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_runs_on_all_platforms() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec"; + let args = serde_json::json!({ + "cmd": "echo 'hello crossplat'", + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "summarize large output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs.get(call_id).expect("missing output"); + + // TODO: Weaker match because windows produces control characters + assert_regex_match(".*hello crossplat.*", &output.output); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index d640c71aa7..2b3de5aa15 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -10,4 +10,4 @@ workspace = true [dependencies] anyhow = { workspace = true } portable-pty = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 14cc430760..23d69b6f6a 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; @@ -9,13 +10,20 @@ use std::time::Duration; use anyhow::Result; use portable_pty::native_pty_system; use portable_pty::CommandBuilder; +use portable_pty::MasterPty; use portable_pty::PtySize; +use portable_pty::SlavePty; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::Mutex as TokioMutex; use tokio::task::JoinHandle; +pub struct PtyPairWrapper { + pub _slave: Option>, + pub _master: Box, +} + #[derive(Debug)] pub struct ExecCommandSession { writer_tx: mpsc::Sender>, @@ -26,6 +34,15 @@ pub struct ExecCommandSession { wait_handle: StdMutex>>, exit_status: Arc, exit_code: Arc>>, + // PtyPair must be preserved because the process will receive Control+C if the + // slave is closed + _pair: StdMutex, +} + +impl fmt::Debug for PtyPairWrapper { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + Ok(()) + } } impl ExecCommandSession { @@ -39,6 +56,7 @@ impl ExecCommandSession { wait_handle: JoinHandle<()>, exit_status: Arc, exit_code: Arc>>, + pair: PtyPairWrapper, ) -> (Self, broadcast::Receiver>) { let initial_output_rx = output_tx.subscribe(); ( @@ -51,6 +69,7 @@ impl ExecCommandSession { wait_handle: StdMutex::new(Some(wait_handle)), exit_status, exit_code, + _pair: StdMutex::new(pair), }, initial_output_rx, ) @@ -192,6 +211,16 @@ pub async fn spawn_pty_process( let _ = exit_tx.send(code); }); + let pair = PtyPairWrapper { + _slave: if cfg!(windows) { + // Keep the slave handle alive on Windows to prevent the process from receiving Control+C + Some(pair.slave) + } else { + None + }, + _master: pair.master, + }; + let (session, output_rx) = ExecCommandSession::new( writer_tx, output_tx, @@ -201,6 +230,7 @@ pub async fn spawn_pty_process( wait_handle, exit_status, exit_code, + pair, ); Ok(SpawnedPty { From 2e4a40252157751765dff176b35c692df8a9fb4e Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:39:23 -0800 Subject: [PATCH 056/159] cloud: status, diff, apply (#7614) Adds cli commands for getting the status of cloud tasks, and for getting/applying the diffs from same. --- codex-rs/Cargo.lock | 23 +- codex-rs/cloud-tasks-client/src/api.rs | 1 + codex-rs/cloud-tasks-client/src/http.rs | 112 +++++++ codex-rs/cloud-tasks-client/src/mock.rs | 9 + codex-rs/cloud-tasks/Cargo.toml | 3 + codex-rs/cloud-tasks/src/app.rs | 12 + codex-rs/cloud-tasks/src/cli.rs | 35 +++ codex-rs/cloud-tasks/src/lib.rs | 374 ++++++++++++++++++++++++ codex-rs/cloud-tasks/src/ui.rs | 26 +- codex-rs/cloud-tasks/src/util.rs | 26 ++ 10 files changed, 594 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d809adfad1..b77cf01b02 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1048,7 +1048,7 @@ dependencies = [ "pretty_assertions", "regex-lite", "serde_json", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "toml", @@ -1088,10 +1088,13 @@ dependencies = [ "codex-login", "codex-tui", "crossterm", + "owo-colors", + "pretty_assertions", "ratatui", "reqwest", "serde", "serde_json", + "supports-color 3.0.2", "tokio", "tokio-stream", "tracing", @@ -1237,7 +1240,7 @@ dependencies = [ "serde", "serde_json", "shlex", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "tracing", @@ -1611,7 +1614,7 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", - "supports-color", + "supports-color 3.0.2", "tempfile", "textwrap 0.16.2", "tokio", @@ -4433,6 +4436,10 @@ name = "owo-colors" version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking" @@ -6168,6 +6175,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index 4bd12939e8..cd8228bc28 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -127,6 +127,7 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 57d39b7bda..f55d0fe797 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -63,6 +63,10 @@ impl CloudBackend for HttpClient { self.tasks_api().list(env).await } + async fn get_task_summary(&self, id: TaskId) -> Result { + self.tasks_api().summary(id).await + } + async fn get_task_diff(&self, id: TaskId) -> Result> { self.tasks_api().diff(id).await } @@ -149,6 +153,75 @@ mod api { Ok(tasks) } + pub(crate) async fn summary(&self, id: TaskId) -> Result { + let id_str = id.0.clone(); + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let parsed: Value = serde_json::from_str(&body).map_err(|e| { + CloudTaskError::Http(format!( + "Decode error for {}: {e}; content-type={ct}; body={body}", + id.0 + )) + })?; + let task_obj = parsed + .get("task") + .and_then(Value::as_object) + .ok_or_else(|| { + CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) + })?; + let status_display = parsed + .get("task_status_display") + .or_else(|| task_obj.get("task_status_display")) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let status = map_status(status_display.as_ref()); + let mut summary = diff_summary_from_status_display(status_display.as_ref()); + if summary.files_changed == 0 + && summary.lines_added == 0 + && summary.lines_removed == 0 + && let Some(diff) = details.unified_diff() + { + summary = diff_summary_from_diff(&diff); + } + let updated_at_raw = task_obj + .get("updated_at") + .and_then(Value::as_f64) + .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) + .or_else(|| latest_turn_timestamp(status_display.as_ref())); + let environment_id = task_obj + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_string); + let environment_label = env_label_from_status_display(status_display.as_ref()); + let attempt_total = attempt_total_from_status_display(status_display.as_ref()); + let title = task_obj + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_review = task_obj + .get("is_review") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(TaskSummary { + id, + title, + status, + updated_at: parse_updated_at(updated_at_raw.as_ref()), + environment_id, + environment_label, + summary, + is_review, + attempt_total, + }) + } + pub(crate) async fn diff(&self, id: TaskId) -> Result> { let (details, body, ct) = self .details_with_body(&id.0) @@ -679,6 +752,34 @@ mod api { .map(str::to_string) } + fn diff_summary_from_diff(diff: &str) -> DiffSummary { + let mut files_changed = 0usize; + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for line in diff.lines() { + if line.starts_with("diff --git ") { + files_changed += 1; + continue; + } + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first() { + Some(b'+') => lines_added += 1, + Some(b'-') => lines_removed += 1, + _ => {} + } + } + if files_changed == 0 && !diff.trim().is_empty() { + files_changed = 1; + } + DiffSummary { + files_changed, + lines_added, + lines_removed, + } + } + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { let mut out = DiffSummary::default(); let Some(map) = v else { return out }; @@ -700,6 +801,17 @@ mod api { out } + fn latest_turn_timestamp(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + latest + .get("updated_at") + .or_else(|| latest.get("created_at")) + .and_then(Value::as_f64) + } + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { let map = v?; let latest = map diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 97bc5520a8..2d03cea029 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,6 +1,7 @@ use crate::ApplyOutcome; use crate::AttemptStatus; use crate::CloudBackend; +use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; @@ -60,6 +61,14 @@ impl CloudBackend for MockClient { Ok(out) } + async fn get_task_summary(&self, id: TaskId) -> Result { + let tasks = self.list_tasks(None).await?; + tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0))) + } + async fn get_task_diff(&self, id: TaskId) -> Result> { Ok(Some(mock_diff_for(&id))) } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index c9edf5b4ad..188538bec6 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -34,6 +34,9 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } +supports-color = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 612c5f6be4..ce12128a3e 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -350,6 +350,7 @@ pub enum AppEvent { mod tests { use super::*; use chrono::Utc; + use codex_cloud_tasks_client::CloudTaskError; struct FakeBackend { // maps env key to titles @@ -385,6 +386,17 @@ mod tests { Ok(out) } + async fn get_task_summary( + &self, + id: TaskId, + ) -> codex_cloud_tasks_client::Result { + self.list_tasks(None) + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) + } + async fn get_task_diff( &self, _id: TaskId, diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 9c118038eb..a7612153b4 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -16,6 +16,12 @@ pub struct Cli { pub enum Command { /// Submit a new Codex Cloud task without launching the TUI. Exec(ExecCommand), + /// Show the status of a Codex Cloud task. + Status(StatusCommand), + /// Apply the diff for a Codex Cloud task locally. + Apply(ApplyCommand), + /// Show the unified diff for a Codex Cloud task. + Diff(DiffCommand), } #[derive(Debug, Args)] @@ -51,3 +57,32 @@ fn parse_attempts(input: &str) -> Result { Err("attempts must be between 1 and 4".to_string()) } } + +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Codex Cloud task identifier to inspect. + #[arg(value_name = "TASK_ID")] + pub task_id: String, +} + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Codex Cloud task identifier to apply. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to apply (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} + +#[derive(Debug, Args)] +pub struct DiffCommand { + /// Codex Cloud task identifier to display. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to display (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 1a3798f758..f73e07f3af 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -8,17 +8,24 @@ pub mod util; pub use cli::Cli; use anyhow::anyhow; +use chrono::Utc; +use codex_cloud_tasks_client::TaskStatus; use codex_login::AuthManager; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use std::cmp::Ordering; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use supports_color::Stream as SupportStream; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; use util::append_error_log; +use util::format_relative_time; use util::set_user_agent_suffix; struct ApplyJob { @@ -193,6 +200,273 @@ fn resolve_query_input(query_arg: Option) -> anyhow::Result { } } +fn parse_task_id(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("task id must not be empty"); + } + let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + let id = without_query + .rsplit('/') + .next() + .unwrap_or(without_query) + .trim(); + if id.is_empty() { + anyhow::bail!("task id must not be empty"); + } + Ok(codex_cloud_tasks_client::TaskId(id.to_string())) +} + +#[derive(Clone, Debug)] +struct AttemptDiffData { + placement: Option, + created_at: Option>, + diff: String, +} + +fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { + match (lhs.placement, rhs.placement) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (lhs.created_at, rhs.created_at) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + } +} + +async fn collect_attempt_diffs( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + task_id: &codex_cloud_tasks_client::TaskId, +) -> anyhow::Result> { + let text = + codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; + let mut attempts = Vec::new(); + if let Some(diff) = + codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? + { + attempts.push(AttemptDiffData { + placement: text.attempt_placement, + created_at: None, + diff, + }); + } + if let Some(turn_id) = text.turn_id { + let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + backend, + task_id.clone(), + turn_id, + ) + .await?; + for sibling in siblings { + if let Some(diff) = sibling.diff { + attempts.push(AttemptDiffData { + placement: sibling.attempt_placement, + created_at: sibling.created_at, + diff, + }); + } + } + } + attempts.sort_by(cmp_attempt); + if attempts.is_empty() { + anyhow::bail!( + "No diff available for task {}; it may still be running.", + task_id.0 + ); + } + Ok(attempts) +} + +fn select_attempt( + attempts: &[AttemptDiffData], + attempt: Option, +) -> anyhow::Result<&AttemptDiffData> { + if attempts.is_empty() { + anyhow::bail!("No attempts available"); + } + let desired = attempt.unwrap_or(1); + let idx = desired + .checked_sub(1) + .ok_or_else(|| anyhow!("attempt must be at least 1"))?; + if idx >= attempts.len() { + anyhow::bail!( + "Attempt {desired} not available; only {} attempt(s) found", + attempts.len() + ); + } + Ok(&attempts[idx]) +} + +fn task_status_label(status: &TaskStatus) -> &'static str { + match status { + TaskStatus::Pending => "PENDING", + TaskStatus::Ready => "READY", + TaskStatus::Applied => "APPLIED", + TaskStatus::Error => "ERROR", + } +} + +fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { + if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { + let base = "no diff"; + return if colorize { + base.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + base.to_string() + }; + } + let adds = summary.lines_added; + let dels = summary.lines_removed; + let files = summary.files_changed; + if colorize { + let adds_raw = format!("+{adds}"); + let adds_str = adds_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(); + let dels_raw = format!("-{dels}"); + let dels_str = dels_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(); + let bullet = "•" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let file_label = "file" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let plural = if files == 1 { "" } else { "s" }; + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + } else { + format!( + "+{adds}/-{dels} • {files} file{}", + if files == 1 { "" } else { "s" } + ) + } +} + +fn format_task_status_lines( + task: &codex_cloud_tasks_client::TaskSummary, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + let status = task_status_label(&task.status); + let status = if colorize { + match task.status { + TaskStatus::Ready => status + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(), + TaskStatus::Pending => status + .if_supports_color(Stream::Stdout, |t| t.magenta()) + .to_string(), + TaskStatus::Applied => status + .if_supports_color(Stream::Stdout, |t| t.blue()) + .to_string(), + TaskStatus::Error => status + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(), + } + } else { + status.to_string() + }; + lines.push(format!("[{status}] {}", task.title)); + let mut meta_parts = Vec::new(); + if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { + if colorize { + meta_parts.push( + label + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(label.to_string()); + } + } else if let Some(id) = task.environment_id.as_deref() { + if colorize { + meta_parts.push( + id.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(id.to_string()); + } + } + let when = format_relative_time(now, task.updated_at); + meta_parts.push(if colorize { + when.as_str() + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + when + }); + let sep = if colorize { + " • " + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + " • ".to_string() + }; + lines.push(meta_parts.join(&sep)); + lines.push(summary_line(&task.summary, colorize)); + lines +} + +async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_status").await?; + let task_id = parse_task_id(&args.task_id)?; + let summary = + codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_status_lines(&summary, now, colorize) { + println!("{line}"); + } + if !matches!(summary.status, TaskStatus::Ready) { + std::process::exit(1); + } + Ok(()) +} + +async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_diff").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + print!("{}", selected.diff); + Ok(()) +} + +async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_apply").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( + &*ctx.backend, + task_id, + Some(selected.diff.clone()), + ) + .await?; + println!("{}", outcome.message); + if !matches!( + outcome.status, + codex_cloud_tasks_client::ApplyStatus::Success + ) { + std::process::exit(1); + } + Ok(()) +} + fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { match status { codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, @@ -322,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(command) = cli.command { return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::Apply(args) => run_apply_command(args).await, + crate::cli::Command::Diff(args) => run_diff_command(args).await, }; } let Cli { .. } = cli; @@ -1713,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { + use super::*; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::MockClient; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + #[test] + fn format_task_status_lines_with_diff_and_label() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[READY] Example task".to_string(), + "Env • 0s ago".to_string(), + "+5/-2 • 3 files".to_string(), + ] + ); + } + + #[test] + fn format_task_status_lines_without_diff_falls_back() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[PENDING] No diff task".to_string(), + "env-2 • 0s ago".to_string(), + "no diff".to_string(), + ] + ); + } + + #[tokio::test] + async fn collect_attempt_diffs_includes_sibling_attempts() { + let backend = MockClient; + let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); + let attempts = collect_attempt_diffs(&backend, &task_id) + .await + .expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].placement, Some(0)); + assert_eq!(attempts[1].placement, Some(1)); + assert!(!attempts[0].diff.is_empty()); + assert!(!attempts[1].diff.is_empty()); + } + + #[test] + fn select_attempt_validates_bounds() { + let attempts = vec![AttemptDiffData { + placement: Some(0), + created_at: None, + diff: "diff --git a/file b/file\n".to_string(), + }]; + let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); + assert_eq!(first.diff, "diff --git a/file b/file\n"); + assert!(select_attempt(&attempts, Some(2)).is_err()); + } + + #[test] + fn parse_task_id_from_url_and_raw() { + let raw = parse_task_id("task_i_abc123").expect("raw id"); + assert_eq!(raw.0, "task_i_abc123"); + let url = + parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); + assert_eq!(url.0, "task_i_123456"); + assert!(parse_task_id(" ").is_err()); + } + #[test] #[ignore = "very slow"] fn composer_input_renders_typed_characters() { diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index e3a97aeb3f..4c41ca576c 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -20,8 +20,7 @@ use std::time::Instant; use crate::app::App; use crate::app::AttemptView; -use chrono::Local; -use chrono::Utc; +use crate::util::format_relative_time_now; use codex_cloud_tasks_client::AttemptStatus; use codex_cloud_tasks_client::TaskStatus; use codex_tui::render_markdown_text; @@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { meta.push(lbl.clone().dim()); } - let when = format_relative_time(t.updated_at).dim(); + let when = format_relative_time_now(t.updated_at).dim(); if !meta.is_empty() { meta.push(" ".into()); meta.push("•".dim()); @@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li ListItem::new(vec![title, meta_line, sub, spacer]) } -fn format_relative_time(ts: chrono::DateTime) -> String { - let now = Utc::now(); - let mut secs = (now - ts).num_seconds(); - if secs < 0 { - secs = 0; - } - if secs < 60 { - return format!("{secs}s ago"); - } - let mins = secs / 60; - if mins < 60 { - return format!("{mins}m ago"); - } - let hours = mins / 60; - if hours < 24 { - return format!("{hours}h ago"); - } - let local = ts.with_timezone(&Local); - local.format("%b %e %H:%M").to_string() -} - fn draw_inline_spinner( frame: &mut Frame, area: Rect, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 1c690b26c0..79513dbcf2 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,4 +1,6 @@ use base64::Engine as _; +use chrono::DateTime; +use chrono::Local; use chrono::Utc; use reqwest::header::HeaderMap; @@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String { } format!("{normalized}/codex/tasks/{task_id}") } + +pub fn format_relative_time(reference: DateTime, ts: DateTime) -> String { + let mut secs = (reference - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +pub fn format_relative_time_now(ts: DateTime) -> String { + format_relative_time(Utc::now(), ts) +} From 952d6c94650acec024ed832ced24d2797f98f787 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 5 Dec 2025 16:24:55 -0800 Subject: [PATCH 057/159] Move justfile to repository root (#7652) ## Summary - move the workspace justfile to the repository root for easier discovery - set the just working directory to codex-rs so existing recipes still run in the Rust workspace ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69334db473108329b0cc253b7fd8218e) --- codex-rs/justfile => justfile | 1 + 1 file changed, 1 insertion(+) rename codex-rs/justfile => justfile (97%) diff --git a/codex-rs/justfile b/justfile similarity index 97% rename from codex-rs/justfile rename to justfile index b1b9d7337b..79b691e0a0 100644 --- a/codex-rs/justfile +++ b/justfile @@ -1,3 +1,4 @@ +set working-directory := "codex-rs" set positional-arguments # Display help From 6c9c563faf5708f0afe2850b831bb276853e3b49 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 5 Dec 2025 16:43:27 -0800 Subject: [PATCH 058/159] fix(apply-patch): preserve CRLF line endings on Windows (#7515) ## Summary This PR is heavily based on #4017, which contains the core logic for the fix. To reduce the risk, we are first introducing it only on windows. We can then expand to wsl / other environments as needed, and then tackle net new files. ## Testing - [x] added unit tests in apply-patch - [x] add integration tests to apply_patch_cli.rs --------- Co-authored-by: Chase Naples --- codex-rs/apply-patch/src/lib.rs | 161 ++++++++++++++++++- codex-rs/core/tests/suite/apply_patch_cli.rs | 91 +++++++++++ 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 867d19a2e8..28dc14eb02 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -699,13 +699,7 @@ fn derive_new_contents_from_chunks( } }; - let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); - - // Drop the trailing empty element that results from the final newline so - // that line counts match the behaviour of standard `diff`. - if original_lines.last().is_some_and(String::is_empty) { - original_lines.pop(); - } + let original_lines: Vec = build_lines_from_contents(&original_contents); let replacements = compute_replacements(&original_lines, path, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); @@ -713,13 +707,67 @@ fn derive_new_contents_from_chunks( if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } - let new_contents = new_lines.join("\n"); + let new_contents = build_contents_from_lines(&original_contents, &new_lines); Ok(AppliedPatch { original_contents, new_contents, }) } +// TODO(dylan-hurd-oai): I think we can migrate to just use `contents.lines()` +// across all platforms. +fn build_lines_from_contents(contents: &str) -> Vec { + if cfg!(windows) { + contents.lines().map(String::from).collect() + } else { + let mut lines: Vec = contents.split('\n').map(String::from).collect(); + + // Drop the trailing empty element that results from the final newline so + // that line counts match the behaviour of standard `diff`. + if lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + + lines + } +} + +fn build_contents_from_lines(original_contents: &str, lines: &[String]) -> String { + if cfg!(windows) { + // for now, only compute this if we're on Windows. + let uses_crlf = contents_uses_crlf(original_contents); + if uses_crlf { + lines.join("\r\n") + } else { + lines.join("\n") + } + } else { + lines.join("\n") + } +} + +/// Detects whether the source file uses Windows CRLF line endings consistently. +/// We only consider a file CRLF-formatted if every newline is part of a +/// CRLF sequence. This avoids rewriting an LF-formatted file that merely +/// contains embedded sequences of "\r\n". +/// +/// Returns `true` if the file uses CRLF line endings, `false` otherwise. +fn contents_uses_crlf(contents: &str) -> bool { + let bytes = contents.as_bytes(); + let mut n_newlines = 0usize; + let mut n_crlf = 0usize; + for i in 0..bytes.len() { + if bytes[i] == b'\n' { + n_newlines += 1; + if i > 0 && bytes[i - 1] == b'\r' { + n_crlf += 1; + } + } + } + + n_newlines > 0 && n_crlf == n_newlines +} + /// Compute a list of replacements needed to transform `original_lines` into the /// new lines, given the patch `chunks`. Each replacement is returned as /// `(start_index, old_len, new_lines)`. @@ -1359,6 +1407,72 @@ PATCH"#, assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } + /// Ensure CRLF line endings are preserved for updated files on Windows‑style inputs. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_line_endings_on_update() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf.txt"); + + // Original file uses CRLF (\r\n) endings. + std::fs::write(&path, b"a\r\nb\r\nc\r\n").unwrap(); + + // Replace `b` -> `B` and append `d`. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + a +-b ++B +@@ + c ++d +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // Expect all CRLF endings; count occurrences of CRLF and ensure there are 4 lines. + let content = String::from_utf8_lossy(&out); + assert!(content.contains("\r\n")); + // No bare LF occurrences immediately preceding a non-CR: the text should not contain "a\nb". + assert!(!content.contains("a\nb")); + // Validate exact content sequence with CRLF delimiters. + assert_eq!(content, "a\r\nB\r\nc\r\nd\r\n"); + } + + /// Ensure CRLF inputs with embedded carriage returns in the content are preserved. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_embedded_carriage_returns_on_append() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf_cr_content.txt"); + + // Original file: first line has a literal '\r' in the content before the CRLF terminator. + std::fs::write(&path, b"foo\r\r\nbar\r\n").unwrap(); + + // Append a new line without modifying existing ones. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++BAZ +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // CRLF endings must be preserved and the extra CR in "foo\r\r" must not be collapsed. + assert_eq!(out.as_slice(), b"foo\r\r\nbar\r\nBAZ\r\n"); + } + #[test] fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); @@ -1544,6 +1658,37 @@ PATCH"#, assert_eq!(expected, diff); } + /// For LF-only inputs with a trailing newline ensure that the helper used + /// on Windows-style builds drops the synthetic trailing empty element so + /// replacements behave like standard `diff` line numbering. + #[test] + fn test_derive_new_contents_lf_trailing_newline() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lf_trailing_newline.txt"); + fs::write(&path, "foo\nbar\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++BAR +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let AppliedPatch { new_contents, .. } = + derive_new_contents_from_chunks(&path, chunks).unwrap(); + + assert_eq!(new_contents, "foo\nBAR\n"); + } + #[test] fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 880e74d959..70c8aa4fa4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1250,3 +1250,94 @@ async fn apply_patch_change_context_disambiguates_target( assert_eq!(contents, "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n"); Ok(()) } + +/// Ensure that applying a patch can update a CRLF file with unicode characters. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_unicode_characters( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("unicode.txt"); + fs::write(&target, "first ⚠️\nsecond ❌\nthird 🔥\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first ⚠️ +-second ❌ ++SECOND ✅ +@@ + third 🔥 ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-unicode-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update unicode characters via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert_eq!(content, "first ⚠️\nSECOND ✅\nthird 🔥\nFOURTH\n"); + Ok(()) +} + +/// Ensure that applying a patch via the CLI preserves CRLF line endings for +/// Windows-style inputs even when updating the file contents. +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_crlf_file_preserves_line_endings( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("crlf.txt"); + fs::write(&target, b"first\r\nsecond\r\nthird\r\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first +-second ++SECOND +@@ + third ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-crlf-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update crlf file via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert!(content.contains("\r\n")); + assert_eq!(content, "first\r\nSECOND\r\nthird\r\nFOURTH\r\n"); + Ok(()) +} From 93f61dbc5febb9a054d729e4b585834628c60bf4 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 5 Dec 2025 18:01:49 -0800 Subject: [PATCH 059/159] Also load skills from repo root. (#7645) Also load skills from /REPO_ROOT/codex/skills. --- codex-rs/core/src/skills/loader.rs | 56 ++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index a9ea156f02..c014af3147 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::git_info::resolve_root_git_project_for_trust; use crate::skills::model::SkillError; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; @@ -20,6 +21,7 @@ struct SkillFrontmatter { const SKILLS_FILENAME: &str = "SKILL.md"; const SKILLS_DIR_NAME: &str = "skills"; +const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; const MAX_NAME_LEN: usize = 100; const MAX_DESCRIPTION_LEN: usize = 500; @@ -65,7 +67,17 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome { } fn skill_roots(config: &Config) -> Vec { - vec![config.codex_home.join(SKILLS_DIR_NAME)] + let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)]; + + if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) { + roots.push( + repo_root + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + ); + } + + roots } fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { @@ -196,6 +208,9 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use pretty_assertions::assert_eq; + use std::path::Path; + use std::process::Command; use tempfile::TempDir; fn make_config(codex_home: &TempDir) -> Config { @@ -211,7 +226,11 @@ mod tests { } fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { - let skill_dir = codex_home.path().join(format!("skills/{dir}")); + write_skill_at(codex_home.path(), dir, name, description) + } + + fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = root.join(format!("skills/{dir}")); fs::create_dir_all(&skill_dir).unwrap(); let indented_description = description.replace('\n', "\n "); let content = format!( @@ -288,4 +307,37 @@ mod tests { "expected length error" ); } + + #[test] + fn loads_skills_from_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + + let status = Command::new("git") + .arg("init") + .current_dir(repo_dir.path()) + .status() + .expect("git init"); + assert!(status.success(), "git init failed"); + + let skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); + let mut cfg = make_config(&codex_home); + cfg.cwd = repo_dir.path().to_path_buf(); + let repo_root = normalize_path(&skills_root).unwrap_or_else(|_| skills_root.clone()); + + let outcome = load_skills(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + let skill = &outcome.skills[0]; + assert_eq!(skill.name, "repo-skill"); + assert!(skill.path.starts_with(&repo_root)); + } } From f521d29726f3b3b0228902754e116f678b8b3a5f Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 6 Dec 2025 05:46:44 +0100 Subject: [PATCH 060/159] fix: OTEL HTTP exporter panic and mTLS support (#7651) This fixes two issues with the OTEL HTTP exporter: 1. **Runtime panic with async reqwest client** The `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses `futures_executor::block_on()` rather than tokio's runtime. When the async reqwest client's timeout mechanism calls `tokio::time::sleep()`, it panics with "there is no reactor running, must be called from the context of a Tokio 1.x runtime". The fix is to use `reqwest::blocking::Client` instead, which doesn't depend on tokio for timeouts. However, the blocking client creates its own internal tokio runtime during construction, which would panic if built from within an async context. We wrap the construction in `tokio::task::block_in_place()` to handle this. 2. **mTLS certificate handling** The HTTP client wasn't properly configured for mTLS, matching the fixes previously done for the model provider client: - Added `.tls_built_in_root_certs(false)` when using a custom CA certificate to ensure only our CA is trusted - Added `.https_only(true)` when using client identity - Added `rustls-tls` feature to ensure rustls is used (required for `Identity::from_pem()` to work correctly) --- codex-rs/otel/Cargo.toml | 4 ++-- codex-rs/otel/src/otel_provider.rs | 28 +++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 059e8e38e6..5ed6c09498 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -29,7 +29,7 @@ opentelemetry-otlp = { workspace = true, features = [ "http-proto", "http-json", "logs", - "reqwest", + "reqwest-blocking-client", "reqwest-rustls", "tls", "tls-roots", @@ -40,7 +40,7 @@ opentelemetry_sdk = { workspace = true, features = [ "rt-tokio", ], optional = true } http = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 8be2431ea9..5495db0ad3 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -182,12 +182,27 @@ fn build_grpc_tls_config( Ok(config) } +/// Build a blocking HTTP client with TLS configuration for the OTLP HTTP exporter. +/// +/// We use `reqwest::blocking::Client` instead of the async client because the +/// `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses +/// `futures_executor::block_on()` rather than tokio. When the async reqwest client's +/// timeout calls `tokio::time::sleep()`, it panics with "no reactor running". fn build_http_client( tls: &OtelTlsConfig, codex_home: &Path, -) -> Result> { - let mut builder = - reqwest::Client::builder().timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); +) -> Result> { + // Wrap in block_in_place because reqwest::blocking::Client creates its own + // internal tokio runtime, which would panic if built directly from an async context. + tokio::task::block_in_place(|| build_http_client_inner(tls, codex_home)) +} + +fn build_http_client_inner( + tls: &OtelTlsConfig, + codex_home: &Path, +) -> Result> { + let mut builder = reqwest::blocking::Client::builder() + .timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); if let Some(path) = tls.ca_certificate.as_ref() { let (pem, location) = read_bytes(codex_home, path)?; @@ -197,7 +212,10 @@ fn build_http_client( location.display() )) })?; - builder = builder.add_root_certificate(certificate); + // Disable built-in root certificates and use only our custom CA + builder = builder + .tls_built_in_root_certs(false) + .add_root_certificate(certificate); } match (&tls.client_certificate, &tls.client_private_key) { @@ -212,7 +230,7 @@ fn build_http_client( key_location.display() )) })?; - builder = builder.identity(identity); + builder = builder.identity(identity).https_only(true); } (Some(_), None) | (None, Some(_)) => { return Err(config_error( From 82090803d9205ba9f7b0b85793dc5c68d63f7cb2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 10:16:47 -0800 Subject: [PATCH 061/159] fix: exec-server stream was erroring for large requests (#7654) Previous to this change, large `EscalateRequest` payloads exceeded the kernel send buffer, causing our single `sendmsg(2)` call (with attached FDs) to be split and retried without proper control handling; this led to `EINVAL`/broken pipe in the `handle_escalate_session_respects_run_in_sandbox_decision()` test when using an `env` with large contents. **Before:** `AsyncSocket::send_with_fds()` called `send_json_message()`, which called `send_message_bytes()`, which made one `socket.sendmsg()` call followed by additional `socket.send()` calls, as necessary: https://github.com/openai/codex/blob/2e4a40252157751765dff176b35c692df8a9fb4e/codex-rs/exec-server/src/posix/socket.rs#L198-L209 **After:** `AsyncSocket::send_with_fds()` now calls `send_stream_frame()`, which calls `send_stream_chunk()` one or more times. Each call to `send_stream_chunk()` calls `socket.sendmsg()`. In the previous implementation, the subsequent `socket.send()` writes had no control information associated with them, whereas in the new `send_stream_chunk()` implementation, a fresh `MsgHdr` (using `with_control()`, as appropriate) is created for `socket.sendmsg()` each time. Additionally, with this PR, stream sending attaches `SCM_RIGHTS` only on the first chunk, and omits control data when there are no FDs, allowing oversized payloads to deliver correctly while preserving FD limits and error checks. --- .../exec-server/src/posix/escalate_server.rs | 8 +- codex-rs/exec-server/src/posix/socket.rs | 165 ++++++++++-------- 2 files changed, 100 insertions(+), 73 deletions(-) diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index b71142d5b1..72934607a3 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -258,12 +258,18 @@ mod tests { }), )); + let mut env = HashMap::new(); + for i in 0..10 { + let value = "A".repeat(1024); + env.insert(format!("CODEX_TEST_VAR{i}"), value); + } + client .send(EscalateRequest { file: PathBuf::from("/bin/echo"), argv: vec!["echo".to_string()], workdir: PathBuf::from("/tmp"), - env: HashMap::new(), + env, }) .await?; diff --git a/codex-rs/exec-server/src/posix/socket.rs b/codex-rs/exec-server/src/posix/socket.rs index 92c93dcc7d..35292367a6 100644 --- a/codex-rs/exec-server/src/posix/socket.rs +++ b/codex-rs/exec-server/src/posix/socket.rs @@ -171,42 +171,24 @@ async fn read_frame_payload( unreachable!("loop exits only after returning payload") } -fn send_message_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { - if fds.len() > MAX_FDS_PER_MESSAGE { +fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { + let control = make_control_message(fds)?; + let payload = [IoSlice::new(data)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + let written = socket.sendmsg(&msg, 0)?; + if written != data.len() { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("too many fds: {}", fds.len()), + std::io::ErrorKind::WriteZero, + format!( + "short datagram write: wrote {written} bytes out of {}", + data.len() + ), )); } - let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + data.len()); - frame.extend_from_slice(&encode_length(data.len())?); - frame.extend_from_slice(data); - - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - unsafe { - let cmsg = control.as_mut_ptr().cast::(); - (*cmsg).cmsg_len = libc::CMSG_LEN(size_of::() as c_uint * fds.len() as c_uint) as _; - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - let data_ptr = libc::CMSG_DATA(cmsg).cast::(); - for (i, fd) in fds.iter().enumerate() { - data_ptr.add(i).write(fd.as_raw_fd()); - } - } - - let payload = [IoSlice::new(&frame)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let mut sent = socket.sendmsg(&msg, 0)?; - while sent < frame.len() { - let bytes = socket.send(&frame[sent..])?; - if bytes == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "socket closed while sending frame payload", - )); - } - sent += bytes; - } Ok(()) } @@ -220,24 +202,16 @@ fn encode_length(len: usize) -> std::io::Result<[u8; LENGTH_PREFIX_SIZE]> { Ok(len_u32.to_le_bytes()) } -pub(crate) fn send_json_message( - socket: &Socket, - msg: T, - fds: &[OwnedFd], -) -> std::io::Result<()> { - let data = serde_json::to_vec(&msg)?; - send_message_bytes(socket, &data, fds) -} - -fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { +fn make_control_message(fds: &[OwnedFd]) -> std::io::Result> { if fds.len() > MAX_FDS_PER_MESSAGE { - return Err(std::io::Error::new( + Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("too many fds: {}", fds.len()), - )); - } - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - if !fds.is_empty() { + )) + } else if fds.is_empty() { + Ok(Vec::new()) + } else { + let mut control = vec![0u8; control_space_for_fds(fds.len())]; unsafe { let cmsg = control.as_mut_ptr().cast::(); (*cmsg).cmsg_len = @@ -249,20 +223,8 @@ fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io data_ptr.add(i).write(fd.as_raw_fd()); } } + Ok(control) } - let payload = [IoSlice::new(data)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let written = socket.sendmsg(&msg, 0)?; - if written != data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - format!( - "short datagram write: wrote {written} bytes out of {}", - data.len() - ), - )); - } - Ok(()) } fn receive_datagram_bytes(socket: &Socket) -> std::io::Result<(Vec, Vec)> { @@ -308,11 +270,11 @@ impl AsyncSocket { msg: T, fds: &[OwnedFd], ) -> std::io::Result<()> { - self.inner - .async_io(Interest::WRITABLE, |socket| { - send_json_message(socket, &msg, fds) - }) - .await + let payload = serde_json::to_vec(&msg)?; + let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + payload.len()); + frame.extend_from_slice(&encode_length(payload.len())?); + frame.extend_from_slice(&payload); + send_stream_frame(&self.inner, &frame, fds).await } pub async fn receive_with_fds Deserialize<'de>>( @@ -343,6 +305,54 @@ impl AsyncSocket { } } +async fn send_stream_frame( + socket: &AsyncFd, + frame: &[u8], + fds: &[OwnedFd], +) -> std::io::Result<()> { + let mut written = 0; + let mut include_fds = !fds.is_empty(); + while written < frame.len() { + let mut guard = socket.writable().await?; + let result = guard.try_io(|inner| { + send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds) + }); + let bytes_written = match result { + Ok(bytes_written) => bytes_written?, + Err(_would_block) => continue, + }; + if bytes_written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "socket closed while sending frame payload", + )); + } + written += bytes_written; + include_fds = false; + } + Ok(()) +} + +fn send_stream_chunk( + socket: &Socket, + frame: &[u8], + fds: &[OwnedFd], + include_fds: bool, +) -> std::io::Result { + let control = if include_fds { + make_control_message(fds)? + } else { + Vec::new() + }; + let payload = [IoSlice::new(frame)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + socket.sendmsg(&msg, 0) +} + pub(crate) struct AsyncDatagramSocket { inner: AsyncFd, } @@ -433,6 +443,17 @@ mod tests { Ok(()) } + #[tokio::test] + async fn async_socket_handles_large_payload() -> std::io::Result<()> { + let (server, client) = AsyncSocket::pair()?; + let payload = vec![b'A'; 10_000]; + let receive_task = tokio::spawn(async move { server.receive::>().await }); + client.send(payload.clone()).await?; + let received_payload = receive_task.await.unwrap()?; + assert_eq!(payload, received_payload); + Ok(()) + } + #[tokio::test] async fn async_datagram_sockets_round_trip_messages() -> std::io::Result<()> { let (server, client) = AsyncDatagramSocket::pair()?; @@ -450,19 +471,19 @@ mod tests { } #[test] - fn send_message_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; + fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_message_bytes(&socket, b"hello", &fds).unwrap_err(); + let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } #[test] - fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; + fn send_stream_chunk_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); + let err = send_stream_chunk(&socket, b"hello", &fds, true).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } From 315b1e957d985ee314679ba0c9b4c38ee780f90e Mon Sep 17 00:00:00 2001 From: Jay Sabva <94957904+JaySabva@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:47:18 +0530 Subject: [PATCH 062/159] docs: fix documentation of rmcp client flag (#7665) ## Summary - Updated the rmcp client flag's documentation in config.md file - changed it from `experimental_use_rmcp_client` to `rmcp_client` --- docs/config.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/config.md b/docs/config.md index 3b06d73019..0ba711f02c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -464,10 +464,11 @@ http_headers = { "HEADER_NAME" = "HEADER_VALUE" } env_http_headers = { "HEADER_NAME" = "ENV_VAR" } ``` -Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `experimental_use_rmcp_client = true` flag: +Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `rmcp_client = true` flag: ```toml -experimental_use_rmcp_client = true +[features] +rmcp_client = true ``` After enabling it, run `codex mcp login ` when the server supports OAuth. @@ -489,17 +490,6 @@ disabled_tools = ["search"] When both `enabled_tools` and `disabled_tools` are specified, Codex first restricts the server to the allow-list and then removes any tools that appear in the deny-list. -#### Experimental RMCP client - -This flag enables OAuth support for streamable HTTP servers. - -```toml -experimental_use_rmcp_client = true - -[mcp_servers.server_name] -… -``` - #### MCP CLI commands ```shell From 9a74228c662b99ea0f4030c55eb781745294e63e Mon Sep 17 00:00:00 2001 From: Jay Sabva <94957904+JaySabva@users.noreply.github.com> Date: Sun, 7 Dec 2025 06:21:07 +0530 Subject: [PATCH 063/159] docs: Remove experimental_use_rmcp_client from config (#7672) Removed experimental Rust MCP client option from config. --- docs/example-config.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/example-config.md b/docs/example-config.md index 6061dc8830..f5b3c62904 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -226,12 +226,6 @@ enable_experimental_windows_sandbox = false # Experimental toggles (legacy; prefer [features]) ################################################################################ -# Use experimental unified exec tool. Default: false -experimental_use_unified_exec_tool = false - -# Use experimental Rust MCP client (enables OAuth for HTTP MCP). Default: false -experimental_use_rmcp_client = false - # Include apply_patch via freeform editing path (affects default tool set). Default: false experimental_use_freeform_apply_patch = false @@ -319,8 +313,6 @@ experimental_use_freeform_apply_patch = false # chatgpt_base_url = "https://chatgpt.com/backend-api/" # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false -# experimental_use_unified_exec_tool = false -# experimental_use_rmcp_client = false # experimental_use_freeform_apply_patch = false # experimental_sandbox_command_assessment = false # tools_web_search = false From b2cb05d562050f6e59b5641e56441b425c0ef54c Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 6 Dec 2025 18:57:08 -0800 Subject: [PATCH 064/159] docs: point dev checks to just (#7673) Update install and contributing guides to use the root justfile helpers (`just fmt`, `just fix -p `, and targeted tests) instead of the older cargo fmt/clippy/test instructions that have been in place since 459363e17b. This matches the justfile relocation to the repo root in 952d6c946 and the current lint/test workflow for CI (see `.github/workflows/rust-ci.yml`). --- docs/contributing.md | 2 +- docs/install.md | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index fc3d5ce836..983d64e6dc 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -25,7 +25,7 @@ If you want to add a new feature or change the behavior of an existing one, plea - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker -- Run **all** checks locally (`cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item`). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `cargo test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. diff --git a/docs/install.md b/docs/install.md index 724a524e3b..b54b74f16c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -24,6 +24,10 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source "$HOME/.cargo/env" rustup component add rustfmt rustup component add clippy +# Install helper tools used by the workspace justfile: +cargo install just +# Optional: install nextest for the `just test` helper (or use `cargo test --all-features` as a fallback) +cargo install cargo-nextest # Build Codex. cargo build @@ -31,10 +35,14 @@ cargo build # Launch the TUI with a sample prompt. cargo run --bin codex -- "explain this codebase to me" -# After making changes, ensure the code is clean. -cargo fmt -- --config imports_granularity=Item -cargo clippy --tests +# After making changes, use the root justfile helpers (they default to codex-rs): +just fmt +just fix -p -# Run the tests. -cargo test +# Run the relevant tests (project-specific is fastest), for example: +cargo test -p codex-tui +# If you have cargo-nextest installed, `just test` runs the full suite: +just test +# Otherwise, fall back to: +cargo test --all-features ``` From 7386e2efbc0696326d382a7c7f754bf02f448d00 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 21:46:07 -0800 Subject: [PATCH 065/159] fix: clear out space on ubuntu runners before running Rust tests (#7678) When I put up https://github.com/openai/codex/pull/7617 for review, initially I started seeing failures on the `ubuntu-24.04` runner used for Rust test runs for the `x86_64-unknown-linux-gnu` architecture. Chat suggested a number of things that could be removed to save space, which seems to help. --- .github/workflows/rust-ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 08c39db69d..f2620dcb7f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -369,6 +369,22 @@ jobs: steps: - uses: actions/checkout@v6 + + # We have been running out of space when running this job on Linux for + # x86_64-unknown-linux-gnu, so remove some unnecessary dependencies. + - name: Remove unnecessary dependencies to save space + if: ${{ startsWith(matrix.runner, 'ubuntu') }} + shell: bash + run: | + set -euo pipefail + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /usr/local/share/boost \ + /usr/local/lib/node_modules \ + /opt/ghc + sudo apt-get remove -y docker.io docker-compose podman buildah + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} From 3c087e8fdadcbf0310fe2d36b972bd9476a0fb37 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 22:11:07 -0800 Subject: [PATCH 066/159] fix: ensure macOS CI runners for Rust tests include recent Homebrew fixes (#7680) As noted in the code comment, we introduced a key fix for `brew` in https://github.com/Homebrew/brew/pull/21157 that Codex needs, but it has not hit stable yet, so we update our CI job to use latest `brew` from `origin/main`. This is necessary for the new integration tests introduced in https://github.com/openai/codex/pull/7617. --- .github/workflows/rust-ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index f2620dcb7f..354b403920 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -385,6 +385,28 @@ jobs: /opt/ghc sudo apt-get remove -y docker.io docker-compose podman buildah + # Ensure brew includes this fix so that brew's shellenv.sh loads + # cleanly in the Codex sandbox (it is frequently eval'd via .zprofile + # for Brew users, including the macOS runners on GitHub): + # + # https://github.com/Homebrew/brew/pull/21157 + # + # Once brew 5.0.5 is released and is the default on macOS runners, this + # step can be removed. + - name: Upgrade brew + if: ${{ startsWith(matrix.runner, 'macos') }} + shell: bash + run: | + set -euo pipefail + brew --version + git -C "$(brew --repo)" fetch origin + git -C "$(brew --repo)" checkout main + git -C "$(brew --repo)" reset --hard origin/main + export HOMEBREW_UPDATE_TO_TAG=0 + brew update + brew upgrade + brew --version + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} From 3c3d3d1adcace87fca7b0e0b9f9b6bef4a8dff72 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 6 Dec 2025 22:39:38 -0800 Subject: [PATCH 067/159] fix: add integration tests for codex-exec-mcp-server with execpolicy (#7617) This PR introduces integration tests that run [codex-shell-tool-mcp](https://www.npmjs.com/package/@openai/codex-shell-tool-mcp) as a user would. Note that this requires running our fork of Bash, so we introduce a [DotSlash](https://dotslash-cli.com/) file for `bash` so that we can run the integration tests on multiple platforms without having to check the binaries into the repository. (As noted in the DotSlash file, it is slightly more heavyweight than necessary, which may be worth addressing as disk space in CI is limited: https://github.com/openai/codex/pull/7678.) To start, this PR adds two tests: - `list_tools()` makes the `list_tools` request to the MCP server and verifies we get the expected response - `accept_elicitation_for_prompt_rule()` defines a `prefix_rule()` with `decision="prompt"` and verifies the elicitation flow works as expected Though the `accept_elicitation_for_prompt_rule()` test **only works on Linux**, as this PR reveals that there are currently issues when running the Bash fork in a read-only sandbox on Linux. This will have to be fixed in a follow-up PR. Incidentally, getting this test run to correctly on macOS also requires a recent fix we made to `brew` that hasn't hit a mainline release yet, so getting CI green in this PR required https://github.com/openai/codex/pull/7680. --- .github/workflows/rust-ci.yml | 13 ++ codex-rs/Cargo.lock | 16 ++ codex-rs/Cargo.toml | 1 + codex-rs/exec-server/Cargo.toml | 4 + codex-rs/exec-server/src/lib.rs | 3 + codex-rs/exec-server/src/posix.rs | 2 + codex-rs/exec-server/src/posix/mcp.rs | 2 +- codex-rs/exec-server/tests/all.rs | 3 + codex-rs/exec-server/tests/common/Cargo.toml | 16 ++ codex-rs/exec-server/tests/common/lib.rs | 167 ++++++++++++++++++ .../tests/suite/accept_elicitation.rs | 131 ++++++++++++++ codex-rs/exec-server/tests/suite/bash | 75 ++++++++ .../exec-server/tests/suite/list_tools.rs | 76 ++++++++ codex-rs/exec-server/tests/suite/mod.rs | 8 + 14 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 codex-rs/exec-server/tests/all.rs create mode 100644 codex-rs/exec-server/tests/common/Cargo.toml create mode 100644 codex-rs/exec-server/tests/common/lib.rs create mode 100644 codex-rs/exec-server/tests/suite/accept_elicitation.rs create mode 100755 codex-rs/exec-server/tests/suite/bash create mode 100644 codex-rs/exec-server/tests/suite/list_tools.rs create mode 100644 codex-rs/exec-server/tests/suite/mod.rs diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 354b403920..0be45540c1 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -407,6 +407,19 @@ jobs: brew upgrade brew --version + # Some integration tests rely on DotSlash being installed. + # See https://github.com/openai/codex/pull/7617. + - name: Install DotSlash + uses: facebook/install-dotslash@v2 + + - name: Pre-fetch DotSlash artifacts + # The Bash wrapper is not available on Windows. + if: ${{ !startsWith(matrix.runner, 'windows') }} + shell: bash + run: | + set -euo pipefail + dotslash -- fetch exec-server/tests/suite/bash + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b77cf01b02..ea1eec8f83 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1256,11 +1256,14 @@ name = "codex-exec-server" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "async-trait", "clap", "codex-core", "codex-execpolicy", + "exec_server_test_support", "libc", + "maplit", "path-absolutize", "pretty_assertions", "rmcp", @@ -1273,6 +1276,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "which", ] [[package]] @@ -2501,6 +2505,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exec_server_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "codex-core", + "rmcp", + "serde_json", + "tokio", +] + [[package]] name = "eyre" version = "0.6.12" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d3d3c36c3e..0587ec1d46 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -96,6 +96,7 @@ codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } +exec_server_test_support = { path = "exec-server/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index ab6ca80a12..a0bd534934 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -56,5 +56,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +assert_cmd = { workspace = true } +exec_server_test_support = { workspace = true } +maplit = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +which = { workspace = true } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index adec09d4de..62f7bbccca 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -6,3 +6,6 @@ pub use posix::main_execve_wrapper; #[cfg(unix)] pub use posix::main_mcp_server; + +#[cfg(unix)] +pub use posix::ExecResult; diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 16da5885f5..1a4b0a0e1f 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -82,6 +82,8 @@ mod mcp_escalation_policy; mod socket; mod stopwatch; +pub use mcp::ExecResult; + /// Default value of --execve option relative to the current executable. /// Note this must match the name of the binary as specified in Cargo.toml. const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper"; diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index bbbddc22e6..1376d46b72 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -54,7 +54,7 @@ pub struct ExecParams { pub login: Option, } -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct ExecResult { pub exit_code: i32, pub output: String, diff --git a/codex-rs/exec-server/tests/all.rs b/codex-rs/exec-server/tests/all.rs new file mode 100644 index 0000000000..7e136e4cce --- /dev/null +++ b/codex-rs/exec-server/tests/all.rs @@ -0,0 +1,3 @@ +// Single integration test binary that aggregates all test modules. +// The submodules live in `tests/suite/`. +mod suite; diff --git a/codex-rs/exec-server/tests/common/Cargo.toml b/codex-rs/exec-server/tests/common/Cargo.toml new file mode 100644 index 0000000000..ba7d2af0a1 --- /dev/null +++ b/codex-rs/exec-server/tests/common/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "exec_server_test_support" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +assert_cmd = { workspace = true } +anyhow = { workspace = true } +codex-core = { workspace = true } +rmcp = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs new file mode 100644 index 0000000000..c6df5c32c7 --- /dev/null +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -0,0 +1,167 @@ +use codex_core::MCP_SANDBOX_STATE_NOTIFICATION; +use codex_core::SandboxState; +use codex_core::protocol::SandboxPolicy; +use rmcp::ClientHandler; +use rmcp::ErrorData as McpError; +use rmcp::RoleClient; +use rmcp::Service; +use rmcp::model::ClientCapabilities; +use rmcp::model::ClientInfo; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::CreateElicitationResult; +use rmcp::model::CustomClientNotification; +use rmcp::model::ElicitationAction; +use rmcp::service::RunningService; +use rmcp::transport::ConfigureCommandExt; +use rmcp::transport::TokioChildProcess; +use serde_json::json; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::process::Command; + +pub fn create_transport

(codex_home: P) -> anyhow::Result +where + P: AsRef, +{ + let mcp_executable = assert_cmd::Command::cargo_bin("codex-exec-mcp-server")?; + let execve_wrapper = assert_cmd::Command::cargo_bin("codex-execve-wrapper")?; + let bash = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("suite") + .join("bash"); + + let transport = + TokioChildProcess::new(Command::new(mcp_executable.get_program()).configure(|cmd| { + cmd.arg("--bash").arg(bash); + cmd.arg("--execve").arg(execve_wrapper.get_program()); + cmd.env("CODEX_HOME", codex_home.as_ref()); + + // Important: pipe stdio so rmcp can speak JSON-RPC over stdin/stdout + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + + // Optional but very helpful while debugging: + cmd.stderr(Stdio::inherit()); + }))?; + + Ok(transport) +} + +pub async fn write_default_execpolicy

(policy: &str, codex_home: P) -> anyhow::Result<()> +where + P: AsRef, +{ + let policy_dir = codex_home.as_ref().join("policy"); + tokio::fs::create_dir_all(&policy_dir).await?; + tokio::fs::write(policy_dir.join("default.codexpolicy"), policy).await?; + Ok(()) +} + +pub async fn notify_readable_sandbox( + sandbox_cwd: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::ReadOnly, + codex_linux_sandbox_exe, + sandbox_cwd: sandbox_cwd.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +pub async fn notify_writable_sandbox_only_one_folder( + writable_folder: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::WorkspaceWrite { + // Note that sandbox_cwd will already be included as a writable root + // when the sandbox policy is expanded. + writable_roots: vec![], + network_access: false, + // Disable writes to temp dir because this is a test, so + // writable_folder is likely also under /tmp and we want to be + // strict about what is writable. + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + codex_linux_sandbox_exe, + sandbox_cwd: writable_folder.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +async fn send_sandbox_notification( + sandbox_state: SandboxState, + service: &RunningService, +) -> anyhow::Result<()> +where + S: Service + ClientHandler, +{ + let sandbox_state_notification = CustomClientNotification::new( + MCP_SANDBOX_STATE_NOTIFICATION, + Some(serde_json::to_value(sandbox_state)?), + ); + service + .send_notification(sandbox_state_notification.into()) + .await?; + Ok(()) +} + +pub struct InteractiveClient { + pub elicitations_to_accept: HashSet, + pub elicitation_requests: Arc>>, +} + +impl ClientHandler for InteractiveClient { + fn get_info(&self) -> ClientInfo { + let capabilities = ClientCapabilities::builder().enable_elicitation().build(); + ClientInfo { + capabilities, + ..Default::default() + } + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParam, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + self.elicitation_requests + .lock() + .unwrap() + .push(request.clone()); + + let accept = self.elicitations_to_accept.contains(&request.message); + async move { + if accept { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({ "approve": true })), + }) + } else { + Ok(CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }) + } + } + } +} diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs new file mode 100644 index 0000000000..2093f9a577 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -0,0 +1,131 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use codex_exec_server::ExecResult; +use exec_server_test_support::InteractiveClient; +use exec_server_test_support::create_transport; +use exec_server_test_support::notify_readable_sandbox; +use exec_server_test_support::write_default_execpolicy; +use maplit::hashset; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::object; +use serde_json::json; +use std::os::unix::fs::symlink; +use tempfile::TempDir; + +/// Verify that when using a read-only sandbox and an execpolicy that prompts, +/// the proper elicitation is sent. Upon auto-approving the elicitation, the +/// command should be run privileged outside the sandbox. +#[tokio::test(flavor = "current_thread")] +async fn accept_elicitation_for_prompt_rule() -> Result<()> { + // Configure a stdio transport that will launch the MCP server using + // $CODEX_HOME with an execpolicy that prompts for `git init` commands. + let codex_home = TempDir::new()?; + write_default_execpolicy( + r#" +# Create a rule with `decision = "prompt"` to exercise the elicitation flow. +prefix_rule( + pattern = ["git", "init"], + decision = "prompt", + match = [ + "git init ." + ], +) +"#, + codex_home.as_ref(), + ) + .await?; + let transport = create_transport(codex_home.as_ref())?; + + // Create an MCP client that approves expected elicitation messages. + let project_root = TempDir::new()?; + let git = which::which("git")?; + let project_root_path = project_root.path().canonicalize().unwrap(); + let expected_elicitation_message = format!( + "Allow agent to run `{} init .` in `{}`?", + git.display(), + project_root_path.display() + ); + let elicitation_requests: Arc>> = Default::default(); + let client = InteractiveClient { + elicitations_to_accept: hashset! { expected_elicitation_message.clone() }, + elicitation_requests: elicitation_requests.clone(), + }; + + // Start the MCP server. + let service: rmcp::service::RunningService = + client.serve(transport).await?; + + // Notify the MCP server about the current sandbox state before making any + // `shell` tool calls. + let linux_sandbox_exe_folder = TempDir::new()?; + let codex_linux_sandbox_exe = if cfg!(target_os = "linux") { + let codex_linux_sandbox_exe = linux_sandbox_exe_folder.path().join("codex-linux-sandbox"); + let codex_cli = assert_cmd::Command::cargo_bin("codex")? + .get_program() + .to_os_string(); + let codex_cli_path = std::path::PathBuf::from(codex_cli); + symlink(&codex_cli_path, &codex_linux_sandbox_exe)?; + Some(codex_linux_sandbox_exe) + } else { + None + }; + notify_readable_sandbox(&project_root_path, codex_linux_sandbox_exe, &service).await?; + + // Call the shell tool and verify that an elicitation was created and + // auto-approved. + let CallToolResult { + content, is_error, .. + } = service + .call_tool(CallToolRequestParam { + name: Cow::Borrowed("shell"), + arguments: Some(object(json!( + { + "command": "git init .", + "workdir": project_root_path.to_string_lossy(), + } + ))), + }) + .await?; + let tool_call_content = content + .first() + .expect("expected non-empty content") + .as_text() + .expect("expected text content"); + let ExecResult { + exit_code, output, .. + } = serde_json::from_str::(&tool_call_content.text)?; + let git_init_succeeded = format!( + "Initialized empty Git repository in {}/.git/\n", + project_root_path.display() + ); + // Normally, this would be an exact match, but it might include extra output + // if `git config set advice.defaultBranchName false` has not been set. + assert!( + output.contains(&git_init_succeeded), + "expected output `{output}` to contain `{git_init_succeeded}`" + ); + assert_eq!(exit_code, 0, "command should succeed"); + assert_eq!(is_error, Some(false), "command should succeed"); + assert!( + project_root_path.join(".git").is_dir(), + "git repo should exist" + ); + + let elicitation_messages = elicitation_requests + .lock() + .unwrap() + .iter() + .map(|r| r.message.clone()) + .collect::>(); + assert_eq!(vec![expected_elicitation_message], elicitation_messages); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/suite/bash b/codex-rs/exec-server/tests/suite/bash new file mode 100755 index 0000000000..5f5d1e5593 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/bash @@ -0,0 +1,75 @@ +#!/usr/bin/env dotslash + +// This is an instance of the fork of Bash that we bundle with +// https://www.npmjs.com/package/@openai/codex-shell-tool-mcp. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests for the MCP server. +// +// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for +// multiple platforms, but we could save a bit of space by making arch-specific +// artifacts available in the GitHub releases and referencing those here. +{ + "name": "codex-bash", + "platforms": { + // macOS 13 builds (and therefore x86_64) were dropped in + // https://github.com/openai/codex/pull/7295, so we only provide an + // Apple Silicon build for now. + "macos-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/bash/macos-15/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + // Note the `musl` parts of the Linux paths are misleading: the Bash + // binaries are actually linked against `glibc`, but the + // `codex-execve-wrapper` that invokes them is linked against `musl`. + "linux-x86_64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + } +} diff --git a/codex-rs/exec-server/tests/suite/list_tools.rs b/codex-rs/exec-server/tests/suite/list_tools.rs new file mode 100644 index 0000000000..17505c7613 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/list_tools.rs @@ -0,0 +1,76 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::fs; +use std::sync::Arc; + +use anyhow::Result; +use exec_server_test_support::create_transport; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::Tool; +use rmcp::model::object; +use serde_json::json; +use tempfile::TempDir; + +/// Verify the list_tools call to the MCP server returns the expected response. +#[tokio::test(flavor = "current_thread")] +async fn list_tools() -> Result<()> { + let codex_home = TempDir::new()?; + let policy_dir = codex_home.path().join("policy"); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("default.codexpolicy"), + r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + )?; + let transport = create_transport(codex_home.path())?; + + let service = ().serve(transport).await?; + let tools = service.list_tools(Default::default()).await?.tools; + assert_eq!( + vec![Tool { + name: Cow::Borrowed("shell"), + title: None, + description: Some(Cow::Borrowed( + "Runs a shell command and returns its output. You MUST provide the workdir as an absolute path." + )), + input_schema: Arc::new(object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "command": { + "description": "The bash string to execute.", + "type": "string", + }, + "login": { + "description": "Launch Bash with -lc instead of -c: defaults to true.", + "nullable": true, + "type": "boolean", + }, + "timeout_ms": { + "description": "The timeout for the command in milliseconds.", + "format": "uint64", + "minimum": 0, + "nullable": true, + "type": "integer", + }, + "workdir": { + "description": "The working directory to execute the command in. Must be an absolute path.", + "type": "string", + }, + }, + "required": [ + "command", + "workdir", + ], + "title": "ExecParams", + "type": "object", + }))), + output_schema: None, + annotations: None, + icons: None, + meta: None + }], + tools + ); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/suite/mod.rs b/codex-rs/exec-server/tests/suite/mod.rs new file mode 100644 index 0000000000..3a94f58579 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/mod.rs @@ -0,0 +1,8 @@ +// TODO(mbolin): Get this test working on Linux. Currently, it fails with: +// +// > Error: Mcp error: -32603: sandbox error: sandbox denied exec error, +// > exit code: 1, stdout: , stderr: Error: failed to send handshake datagram +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod accept_elicitation; +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] +mod list_tools; From 53a486f7ea370dfc34a1b46214b7456d69e5ee3c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 7 Dec 2025 09:47:48 -0800 Subject: [PATCH 068/159] Add remote models feature flag (#7648) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/codex-api/src/endpoint/models.rs | 5 +- .../codex-api/tests/models_integration.rs | 17 +- codex-rs/core/src/codex.rs | 10 + codex-rs/core/src/features.rs | 8 + .../core/src/openai_models/model_family.rs | 7 +- .../core/src/openai_models/models_manager.rs | 22 ++- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 183 ++++++++++++++++++ codex-rs/protocol/src/openai_models.rs | 64 ++++-- codex-rs/protocol/src/protocol.rs | 2 +- 10 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 codex-rs/core/tests/suite/remote_models.rs diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index fec8d7f292..39f7b30c32 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -181,12 +181,13 @@ mod tests { "display_name": "gpt-test", "description": "desc", "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "high"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 99, 0], "supported_in_api": true, - "priority": 1 + "priority": 1, + "upgrade": null, })) .unwrap(), ], diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3b4077f534..fff9c53f7a 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use http::HeaderMap; use http::Method; use wiremock::Mock; @@ -57,15 +58,25 @@ async fn models_client_hits_models_endpoint() { description: Some("desc".to_string()), default_reasoning_level: ReasoningEffort::Medium, supported_reasoning_levels: vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: ReasoningEffort::Low.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: ReasoningEffort::High.to_string(), + }, ], shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, }], }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 89435ee6d0..cc758eaedf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1470,6 +1470,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = sess + .services + .models_manager + .refresh_available_models(&config.model_provider) + .await + { + error!("failed to refresh available models: {err}"); + } + // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1d775360c4..69442815e7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -54,6 +54,8 @@ pub enum Feature { WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) RemoteCompaction, + /// Refresh remote models and emit AppReady once the list is available. + RemoteModels, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). @@ -333,6 +335,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, + FeatureSpec { + id: Feature::RemoteModels, + key: "remote_models", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::ParallelToolCalls, key: "parallel", diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6ee18ad9e3..507e1a48d9 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -291,6 +291,7 @@ mod tests { use super::*; use codex_protocol::openai_models::ClientVersion; use codex_protocol::openai_models::ModelVisibility; + use codex_protocol::openai_models::ReasoningEffortPreset; fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo { ModelInfo { @@ -298,12 +299,16 @@ mod tests { display_name: slug.to_string(), description: Some(format!("{slug} desc")), default_reasoning_level: effort, - supported_reasoning_levels: vec![effort], + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort, + description: effort.to_string(), + }], shell_type: shell, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, } } diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 22edf04ffe..55c11f4554 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -36,7 +36,6 @@ impl ModelsManager { } } - // do not use this function yet. It's work in progress. pub async fn refresh_available_models( &self, provider: &ModelProviderInfo, @@ -47,16 +46,21 @@ impl ModelsManager { let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); + let mut client_version = env!("CARGO_PKG_VERSION"); + if client_version == "0.0.0" { + client_version = "99.99.99"; + } let response = client - .list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new()) + .list_models(client_version, HeaderMap::new()) .await .map_err(map_api_error)?; let models = response.models; *self.remote_models.write().await = models.clone(); + let available_models = self.build_available_models().await; { let mut available_models_guard = self.available_models.write().await; - *available_models_guard = self.build_available_models().await; + *available_models_guard = available_models; } Ok(models) } @@ -75,8 +79,11 @@ impl ModelsManager { async fn build_available_models(&self) -> Vec { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); - let mut model_presets: Vec = - available_models.into_iter().map(Into::into).collect(); + let mut model_presets: Vec = available_models + .into_iter() + .map(Into::into) + .filter(|preset: &ModelPreset| preset.show_in_picker) + .collect(); if let Some(default) = model_presets.first_mut() { default.is_default = true; } @@ -103,12 +110,13 @@ mod tests { "display_name": display, "description": format!("{display} desc"), "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 1, 0], "supported_in_api": true, - "priority": priority + "priority": priority, + "upgrade": null, })) .expect("valid model") } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e2d78004a5..2112cbb7aa 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -41,6 +41,7 @@ mod otel; mod prompt_caching; mod quota_exceeded; mod read_file; +mod remote_models; mod resume; mod review; mod rmcp_client; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs new file mode 100644 index 0000000000..4178ed1c2a --- /dev/null +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -0,0 +1,183 @@ +#![cfg(not(target_os = "windows"))] +// unified exec is not supported on Windows OS +use std::sync::Arc; + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use serde_json::json; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +const REMOTE_MODEL_SLUG: &str = "codex-test"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_model = ModelInfo { + slug: REMOTE_MODEL_SLUG.to_string(), + display_name: "Remote Test".to_string(), + description: Some("A remote model that requires the test shell".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + }; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = "gpt-5.1".to_string(); + }); + + let TestCodex { + codex, + cwd, + config, + conversation_manager, + .. + } = builder.build(&server).await?; + + let models_manager = conversation_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + + assert_eq!(available_model.model, REMOTE_MODEL_SLUG); + + let requests = models_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected a single /models refresh request for the remote models feature" + ); + assert_eq!(requests[0].url.path(), "/v1/models"); + + let family = models_manager + .construct_model_family(REMOTE_MODEL_SLUG, &config) + .await; + assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec); + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(REMOTE_MODEL_SLUG.to_string()), + effort: None, + summary: None, + }) + .await?; + + let call_id = "call"; + let args = json!({ + "cmd": "/bin/echo call", + "yield_time_ms": 250, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run call".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REMOTE_MODEL_SLUG.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), + _ => None, + }) + .await; + + assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + Ok(()) +} + +async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(model) = { + let guard = manager.available_models.read().await; + guard.iter().find(|model| model.model == slug).cloned() + } { + return model; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 02d50627ca..0804811a3f 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -36,7 +37,7 @@ pub enum ReasoningEffort { } /// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ReasoningEffortPreset { /// Effort level that the model supports. pub effort: ReasoningEffort, @@ -123,7 +124,7 @@ pub struct ModelInfo { #[serde(default)] pub description: Option, pub default_reasoning_level: ReasoningEffort, - pub supported_reasoning_levels: Vec, + pub supported_reasoning_levels: Vec, pub shell_type: ConfigShellToolType, #[serde(default = "default_visibility")] pub visibility: ModelVisibility, @@ -132,6 +133,8 @@ pub struct ModelInfo { pub supported_in_api: bool, #[serde(default)] pub priority: i32, + #[serde(default)] + pub upgrade: Option, } /// Response wrapper for `/models`. @@ -149,22 +152,57 @@ impl From for ModelPreset { fn from(info: ModelInfo) -> Self { ModelPreset { id: info.slug.clone(), - model: info.slug, + model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), default_reasoning_effort: info.default_reasoning_level, - supported_reasoning_efforts: info - .supported_reasoning_levels - .into_iter() - .map(|level| ReasoningEffortPreset { - effort: level, - // todo: add description for each reasoning effort - description: level.to_string(), - }) - .collect(), + supported_reasoning_efforts: info.supported_reasoning_levels.clone(), is_default: false, // default is the highest priority available model - upgrade: None, // no upgrade available (todo: think about it) + upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade { + id: upgrade_slug.clone(), + reasoning_effort_mapping: reasoning_effort_mapping_from_presets( + &info.supported_reasoning_levels, + ), + migration_config_key: info.slug.clone(), + }), show_in_picker: info.visibility == ModelVisibility::List, } } } + +fn reasoning_effort_mapping_from_presets( + presets: &[ReasoningEffortPreset], +) -> Option> { + if presets.is_empty() { + return None; + } + + // Map every canonical effort to the closest supported effort for the new model. + let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let mut map = HashMap::new(); + for effort in ReasoningEffort::iter() { + let nearest = nearest_effort(effort, &supported); + map.insert(effort, nearest); + } + Some(map) +} + +fn effort_rank(effort: ReasoningEffort) -> i32 { + match effort { + ReasoningEffort::None => 0, + ReasoningEffort::Minimal => 1, + ReasoningEffort::Low => 2, + ReasoningEffort::Medium => 3, + ReasoningEffort::High => 4, + ReasoningEffort::XHigh => 5, + } +} + +fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let target_rank = effort_rank(target); + supported + .iter() + .copied() + .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .unwrap_or(target) +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 225a622dcc..89b5fd315a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1348,7 +1348,7 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { Agent, From acb8ed493f588911da02b3fe0ac2e552d8b717f0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 8 Dec 2025 02:49:51 -0600 Subject: [PATCH 069/159] Fixed regression for chat endpoint; missing tools name caused litellm proxy to crash (#7724) This PR addresses https://github.com/openai/codex/issues/7051 --- codex-rs/core/src/tools/spec.rs | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e72becd882..89a71b0edc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -804,10 +804,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api( } if let Some(map) = tool.as_object_mut() { + let name = map + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // Remove "type" field as it is not needed in chat completions. map.remove("type"); Some(json!({ "type": "function", + "name": name, "function": map, })) } else { @@ -2083,4 +2089,58 @@ Examples of valid command strings: }) ); } + + #[test] + fn chat_tools_include_top_level_name() { + let mut properties = BTreeMap::new(); + properties.insert("foo".to_string(), JsonSchema::String { description: None }); + let tools = vec![ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: None, + }, + })]; + + let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); + assert_eq!( + responses_json, + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); + + let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap(); + + assert_eq!( + tools_json, + vec![json!({ + "type": "function", + "name": "demo", + "function": { + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + } + })] + ); + } } From 57ba9fa100f8589a49f0bc65b4050f50d73bf30b Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Mon, 8 Dec 2025 17:20:23 +0800 Subject: [PATCH 070/159] =?UTF-8?q?fix(doc):=20TOML=20otel=20exporter=20ex?= =?UTF-8?q?ample=20=E2=80=94=20multi-line=20inline=20table=20is=20inv?= =?UTF-8?q?=E2=80=A6=20(#7669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …alid (#7668) The `otel` exporter example in `docs/config.md` is misleading and will cause the configuration parser to fail if copied verbatim. Summary ------- The example uses a TOML inline table but spreads the inline-table braces across multiple lines. TOML inline tables must be contained on a single line (`key = { a = 1, b = 2 }`); placing newlines inside the braces triggers a parse error in most TOML parsers and prevents Codex from starting. Reproduction ------------ 1. Paste the snippet below into `~/.codex/config.toml` (or your project config). 2. Run `codex` (or the command that loads the config). 3. The process will fail to start with a TOML parse error similar to: ```text Error loading config.toml: TOML parse error at line 55, column 27 | 55 | exporter = { otlp-http = { | ^ newlines are unsupported in inline tables, expected nothing ``` Problematic snippet (as currently shown in the docs) --------------------------------------------------- ```toml [otel] exporter = { otlp-http = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } }} ``` Recommended fixes ------------------ ```toml [otel.exporter."otlp-http"] endpoint = "https://otel.example.com/v1/logs" protocol = "binary" [otel.exporter."otlp-http".headers] "x-otlp-api-key" = "${OTLP_TOKEN}" ``` Or, keep an inline table but write it on one line (valid but less readable): ```toml [otel] exporter = { "otlp-http" = { endpoint = "https://otel.example.com/v1/logs", protocol = "binary", headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } } } ``` --- docs/config.md | 39 ++++++++++++++++++--------------------- docs/example-config.md | 42 ++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/docs/config.md b/docs/config.md index 0ba711f02c..b7d44142aa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -615,12 +615,12 @@ Set `otel.exporter` to control where events go: endpoint, protocol, and headers your collector expects: ```toml - [otel] - exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } - }} + [otel.exporter."otlp-http"] + endpoint = "https://otel.example.com/v1/logs" + protocol = "binary" + + [otel.exporter."otlp-http".headers] + "x-otlp-api-key" = "${OTLP_TOKEN}" ``` - `otlp-grpc` – streams OTLP log records over gRPC. Provide the endpoint and any @@ -628,27 +628,24 @@ Set `otel.exporter` to control where events go: ```toml [otel] - exporter = { otlp-grpc = { - endpoint = "https://otel.example.com:4317", - headers = { "x-otlp-meta" = "abc123" } - }} + exporter = { otlp-grpc = {endpoint = "https://otel.example.com:4317",headers = { "x-otlp-meta" = "abc123" }}} ``` Both OTLP exporters accept an optional `tls` block so you can trust a custom CA or enable mutual TLS. Relative paths are resolved against `~/.codex/`: ```toml -[otel] -exporter = { otlp-http = { - endpoint = "https://otel.example.com/v1/logs", - protocol = "binary", - headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, - tls = { - ca-certificate = "certs/otel-ca.pem", - client-certificate = "/etc/codex/certs/client.pem", - client-private-key = "/etc/codex/certs/client-key.pem", - } -}} +[otel.exporter."otlp-http"] +endpoint = "https://otel.example.com/v1/logs" +protocol = "binary" + +[otel.exporter."otlp-http".headers] +"x-otlp-api-key" = "${OTLP_TOKEN}" + +[otel.exporter."otlp-http".tls] +ca-certificate = "certs/otel-ca.pem" +client-certificate = "/etc/codex/certs/client.pem" +client-private-key = "/etc/codex/certs/client-key.pem" ``` If the exporter is `none` nothing is written anywhere; otherwise you must run or point to your diff --git a/docs/example-config.md b/docs/example-config.md index f5b3c62904..1f326ac14b 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -341,30 +341,28 @@ environment = "dev" exporter = "none" # Example OTLP/HTTP exporter configuration -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", # "binary" | "json" -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" # "binary" | "json" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" # Example OTLP/gRPC exporter configuration -# [otel] -# exporter = { otlp-grpc = { -# endpoint = "https://otel.example.com:4317", -# headers = { "x-otlp-meta" = "abc123" } -# }} +# [otel.exporter."otlp-grpc"] +# endpoint = "https://otel.example.com:4317", +# headers = { "x-otlp-meta" = "abc123" } # Example OTLP exporter with mutual TLS -# [otel] -# exporter = { otlp-http = { -# endpoint = "https://otel.example.com/v1/logs", -# protocol = "binary", -# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }, -# tls = { -# ca-certificate = "certs/otel-ca.pem", -# client-certificate = "/etc/codex/certs/client.pem", -# client-private-key = "/etc/codex/certs/client-key.pem", -# } -# }} +# [otel.exporter."otlp-http"] +# endpoint = "https://otel.example.com/v1/logs" +# protocol = "binary" + +# [otel.exporter."otlp-http".headers] +# "x-otlp-api-key" = "${OTLP_TOKEN}" + +# [otel.exporter."otlp-http".tls] +# ca-certificate = "certs/otel-ca.pem" +# client-certificate = "/etc/codex/certs/client.pem" +# client-private-key = "/etc/codex/certs/client-key.pem" ``` From 98923654d008f74a85b1af4174fe252439fdb359 Mon Sep 17 00:00:00 2001 From: gameofby Date: Mon, 8 Dec 2025 17:23:21 +0800 Subject: [PATCH 071/159] fix: refine the warning message and docs for deprecated tools config (#7685) Issue #7661 revealed that users are confused by deprecation warnings like: > `tools.web_search` is deprecated. Use `web_search_request` instead. This message misleadingly suggests renaming the config key from `web_search` to `web_search_request`, when the actual required change is to **move and rename the configuration from the `[tools]` section to the `[features]` section**. This PR clarifies the warning messages and documentation to make it clear that deprecated `[tools]` configurations should be moved to `[features]`. Changes made: - Updated deprecation warning format in `codex-rs/core/src/codex.rs:520` to include `[features].` prefix - Updated corresponding test expectations in `codex-rs/core/tests/suite/deprecation_notice.rs:39` - Improved documentation in `docs/config.md` to clarify upfront that `[tools]` options are deprecated in favor of `[features]` --- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/tests/suite/deprecation_notice.rs | 2 +- docs/config.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cc758eaedf..c33904e2fd 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -536,7 +536,7 @@ impl Session { for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); - let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); + let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead."); let details = if alias == canonical { None } else { diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 4e240f0a07..bab715ebd8 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let DeprecationNoticeEvent { summary, details } = notice; assert_eq!( summary, - "`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead." + "`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead." .to_string(), ); assert_eq!( diff --git a/docs/config.md b/docs/config.md index b7d44142aa..08ff2aa349 100644 --- a/docs/config.md +++ b/docs/config.md @@ -350,6 +350,8 @@ Though using this option may also be necessary if you try to use Codex in enviro ### tools.\* +These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)). + Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default: ```toml @@ -358,8 +360,6 @@ web_search = true # allow Codex to issue first-party web searches without prom view_image = false # disable image uploads (they're enabled by default) ``` -`web_search` is deprecated; use the `web_search_request` feature flag instead. - The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. ### approval_presets From 056c2ee2765aa951f74a7e8ad5f40e503baa2712 Mon Sep 17 00:00:00 2001 From: Pavel <19418601+rakleed@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:47:33 +0300 Subject: [PATCH 072/159] fix: update URLs to use HTTPS in model migration prompts (#7705) Update URLs to use HTTPS in model migration prompts Closes #6685 --- codex-rs/tui/src/model_migration.rs | 6 ++++-- ...tui__model_migration__tests__model_migration_prompt.snap | 2 +- ...migration__tests__model_migration_prompt_gpt5_codex.snap | 2 +- ...tion__tests__model_migration_prompt_gpt5_codex_mini.snap | 2 +- ...igration__tests__model_migration_prompt_gpt5_family.snap | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1f93fd9a4f..cbce1f1bb0 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -292,7 +292,9 @@ fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1-codex-max".cyan().underlined(), + "https://openai.com/index/gpt-5-1-codex-max/" + .cyan() + .underlined(), ".".into(), ]), ], @@ -312,7 +314,7 @@ fn gpt5_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1".cyan().underlined(), + "https://openai.com/index/gpt-5-1/".cyan().underlined(), ".".into(), ]), Line::from(vec!["Press enter to continue".dim()]), diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap index 5b3136803f..1f95142169 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -9,7 +9,7 @@ expression: terminal.backend() than its predecessors and capable of long-running project-scale work. - Learn more at www.openai.com/index/gpt-5-1-codex-max. + Learn more at https://openai.com/index/gpt-5-1-codex-max/. Choose how you'd like Codex to proceed. diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap index 5a0ccd9b5b..52718e5793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue From 701f42b74bed4ba4df93d5be740505a017e105a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:55:06 -0800 Subject: [PATCH 073/159] chore(deps): bump ts-rs from 11.0.1 to 11.1.0 in /codex-rs (#7713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ts-rs](https://github.com/Aleph-Alpha/ts-rs) from 11.0.1 to 11.1.0.

Release notes

Sourced from ts-rs's releases.

v11.1.0

Today, we're happy to publish a small follow-up to v11.0.1!

This release fixes a nasty build failure when using the format feature. Note: For those that use the format feature, this release bumps the MSRV to 1.88. We'd have preferred to do this in a major release, but felt this was acceptable since the build was broken by one of the dependencies anyway.

New features

TypeScript enums with #[ts(repr(enum))

#[ts(repr(enum)) instructs ts-rs to generate an enum, instead of a type for your rust enum.

#[derive(TS)]
#[ts(repr(enum))]
enum Role {
    User,
    Admin,
}
// will generate `export enum Role { "User", "Admin"
}`

Discriminants are preserved, and you can use the variant's name as discriminant instead using #[ts(repr(enum = name))]

#[ts(optional_fields)] in enums

The #[ts(optional_fields)] attribute can now be applied directly to enums, or even to individual enum variants.

Control over file extensions in imports

Normally, we generate import { Type } from "file" statements. In some scenarios though, it might be necessary to use a .ts or even .js extension instead.
This is now possible by setting the TS_RS_IMPORT_EXTENSION environment variable.

Note: With the introduction of this feature, we deprecate the import-esm cargo feature. It will be removed in a future major release.

Full changelog

New Contributors

Changelog

Sourced from ts-rs's changelog.

11.1.0

Features

  • Add #[ts(repr(enum))] attribute (#425)
  • Add support for #[ts(optional_fields)] in enums and enum variants (#432)
  • Deprecate import-esm cargo feature in favour of RS_RS_IMPORT_EXTENSION (#423)

Fixes

  • Fix bindings for chrono::Duration (#434)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ts-rs&package-manager=cargo&previous-version=11.0.1&new-version=11.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ea1eec8f83..3331ca3b8c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2561,7 +2561,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3464,7 +3464,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5252,7 +5252,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6937,9 +6937,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", "thiserror 2.0.17", @@ -6949,9 +6949,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", @@ -7433,7 +7433,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] From 7a6d6090d7944a98f30301480381cd1aa7520f62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:58:50 -0800 Subject: [PATCH 074/159] chore(deps): bump derive_more from 2.0.1 to 2.1.0 in /codex-rs (#7714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [derive_more](https://github.com/JelteF/derive_more) from 2.0.1 to 2.1.0.
Release notes

Sourced from derive_more's releases.

2.1.0

Added

  • Support #[display(rename_all = "<casing>")] attribute to change output for implicit naming of unit enum variants or unit structs when deriving Display. (#443)
  • Support #[from_str(rename_all = "<casing>")] attribute for unit enum variants and unit structs when deriving FromStr. (#467)
  • Support Option fields for Error::source() in Error derive. (#459)
  • Support structs with no fields in FromStr derive. (#469)
  • Add PartialEq derive similar to std's one, but considering generics correctly, and implementing ne() method as well. (#473, #475)
  • Add Eq derive similar to std's one, but considering generics correctly. (#479)
  • Proxy-pass #[allow]/#[expect] attributes of the type in Constructor derive. (#477)
  • Support Deref and DerefMut derives for enums. (#485)
  • Support custom error in FromStr derive. (#494)
  • Support custom error in TryInto derive. (#503)
  • Support skipping fields in Add-like, AddAssign-like, Mul-like and MulAssign-like derives. (#472)

Changed

  • The minimum supported Rust version (MSRV) is now Rust 1.81. (#466)
  • Add-like, AddAssign-like, Mul-like and MulAssign-like derives now infer trait bounds for generics structurally (bound field types instead of type parameters directly). (#472)

Fixed

  • Suppress deprecation warnings in generated code. (#454)
  • Silent no-op when #[try_from(repr)] attribute is not specified for TryFrom derive. (#458)
  • Missing trait bounds in AsRef/AsMut derives when associative types are involved. (#474)
  • Erroneous code generated in Try/TryInto derives when Self type is present in the struct or enum definition. (#489)
  • Dependency on unstable feature(error_generic_member_access) in Error derive when using Backtrace on a non-nightly toolchain. (#513)
  • Broken support for #[<display-trait>("default formatting")] attribute without {_variant} being used as default for enum variants without explicit formatting. (#495)

New Contributors

Full Changelog: https://github.com/JelteF/derive_more/compare/v2.0.1...v2.1.0

Changelog

Sourced from derive_more's changelog.

2.1.0 - 2025-12-02

Added

  • Support #[display(rename_all = "<casing>")] attribute to change output for implicit naming of unit enum variants or unit structs when deriving Display. (#443)
  • Support #[from_str(rename_all = "<casing>")] attribute for unit enum variants and unit structs when deriving FromStr. (#467)
  • Support Option fields for Error::source() in Error derive. (#459)
  • Support structs with no fields in FromStr derive. (#469)
  • Add PartialEq derive similar to std's one, but considering generics correctly, and implementing ne() method as well. (#473, #475)
  • Add Eq derive similar to std's one, but considering generics correctly. (#479)
  • Proxy-pass #[allow]/#[expect] attributes of the type in Constructor derive. (#477)
  • Support Deref and DerefMut derives for enums. (#485)
  • Support custom error in FromStr derive. (#494)
  • Support custom error in TryInto derive. (#503)
  • Support skipping fields in Add-like, AddAssign-like, Mul-like and MulAssign-like derives. (#472)

Changed

  • The minimum supported Rust version (MSRV) is now Rust 1.81. (#466)
  • Add-like, AddAssign-like, Mul-like and MulAssign-like derives now infer trait bounds for generics structurally (bound field types instead of type parameters directly). (#472)

Fixed

  • Suppress deprecation warnings in generated code. (#454)
  • Silent no-op when #[try_from(repr)] attribute is not specified for TryFrom derive. (#458)
  • Missing trait bounds in AsRef/AsMut derives when associative types are involved. (#474)
  • Erroneous code generated in Try/TryInto derives when Self type is present in

... (truncated)

Commits
  • c354bad Prepare 2.1.0 release (#521)
  • 983875f Allow using enum-level attributes for non-Display formatting traits as defa...
  • 2d3805b Allow skipping fields for Add/AddAssign/Mul/MulAssign-like derives (#...
  • 1b5d314 Upgrade convert_case requirement from 0.9 to 0.10 version (#520)
  • c32d0a0 Upgrade actions/checkout from 5 to 6 version (#519)
  • 905f5a3 Upgrade convert_case crate from 0.8 to 0.9 version (#517)
  • 8e9104d Support syn::ExprCall and syn::ExprClosure for custom errors (#516, #112)
  • be3edc4 Update compile_fail tests for 1.91 Rust (#515)
  • 929dd41 Support custom error type in TryInto derive (#503, #396)
  • 4fc6827 Remove unstable feature requirement when deriving Backtraced Error (#513,...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=derive_more&package-manager=cargo&previous-version=2.0.1&new-version=2.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3331ca3b8c..a3ad04346c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1302,7 +1302,7 @@ dependencies = [ "allocative", "anyhow", "clap", - "derive_more 2.0.1", + "derive_more 2.1.0", "env_logger", "log", "multimap", @@ -1593,7 +1593,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "crossterm", - "derive_more 2.0.1", + "derive_more 2.1.0", "diffy", "dirs", "dunce", @@ -1795,9 +1795,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -2136,11 +2136,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ - "derive_more-impl 2.0.1", + "derive_more-impl 2.1.0", ] [[package]] @@ -2158,13 +2158,14 @@ dependencies = [ [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.104", "unicode-xid", ] From 9fa9e3e7bbbe09a363359570896d335a3c4e7624 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:59:19 -0800 Subject: [PATCH 075/159] chore(deps): bump insta from 1.43.2 to 1.44.3 in /codex-rs (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [insta](https://github.com/mitsuhiko/insta) from 1.43.2 to 1.44.3.
Release notes

Sourced from insta's releases.

1.44.3

Release Notes

  • Fix a regression in 1.44.2 where merge conflict detection was too aggressive, incorrectly flagging snapshot content containing ====== or similar patterns as conflicts. #832
  • Fix a regression in 1.42.2 where inline snapshot updates would corrupt the file when code preceded the macro (e.g., let output = assert_snapshot!(...)). #833

Install cargo-insta 1.44.3

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/mitsuhiko/insta/releases/download/1.44.3/cargo-insta-installer.sh
| sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm
https://github.com/mitsuhiko/insta/releases/download/1.44.3/cargo-insta-installer.ps1
| iex"

Download cargo-insta 1.44.3

File Platform Checksum
cargo-insta-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
cargo-insta-x86_64-apple-darwin.tar.xz Intel macOS checksum
cargo-insta-x86_64-pc-windows-msvc.zip x64 Windows checksum
cargo-insta-x86_64-unknown-linux-gnu.tar.xz x64 Linux checksum
cargo-insta-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

1.44.2

Release Notes

  • Fix a rare backward compatibility issue where inline snapshots using an uncommon legacy format (single-line content stored in multiline raw strings) could fail to match after 1.44.0. #830
  • Handle merge conflicts in snapshot files gracefully. When a snapshot file contains git merge conflict markers, insta now detects them and treats the snapshot as missing, allowing tests to continue and create a new pending snapshot for review. #829
  • Skip nextest_doctest tests when cargo-nextest is not installed. #826
  • Fix functional tests failing under nextest due to inherited NEXTEST_RUN_ID environment variable. #824

Install cargo-insta 1.44.2

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/mitsuhiko/insta/releases/download/1.44.2/cargo-insta-installer.sh
| sh

Install prebuilt binaries via powershell script

powershell -ExecutionPolicy Bypass -c "irm
https://github.com/mitsuhiko/insta/releases/download/1.44.2/cargo-insta-installer.ps1
| iex"
</tr></table>

... (truncated)

Changelog

Sourced from insta's changelog.

1.44.3

  • Fix a regression in 1.44.2 where merge conflict detection was too aggressive, incorrectly flagging snapshot content containing ====== or similar patterns as conflicts. #832
  • Fix a regression in 1.42.2 where inline snapshot updates would corrupt the file when code preceded the macro (e.g., let output = assert_snapshot!(...)). #833

1.44.2

  • Fix a rare backward compatibility issue where inline snapshots using an uncommon legacy format (single-line content stored in multiline raw strings) could fail to match after 1.44.0. #830
  • Handle merge conflicts in snapshot files gracefully. When a snapshot file contains git merge conflict markers, insta now detects them and treats the snapshot as missing, allowing tests to continue and create a new pending snapshot for review. #829
  • Skip nextest_doctest tests when cargo-nextest is not installed. #826
  • Fix functional tests failing under nextest due to inherited NEXTEST_RUN_ID environment variable. #824

1.44.1

  • Add --dnd alias for --disable-nextest-doctest flag to make it easier to silence the deprecation warning. #822
  • Update cargo-dist to 0.30.2 and fix Windows runner to use windows-2022. #821

1.44.0

  • Added non-interactive snapshot review and reject modes for use in non-TTY environments (LLMs, CI pipelines, scripts). cargo insta review --snapshot <path> and cargo insta reject --snapshot <path> now work without a terminal. Enhanced pending-snapshots output with usage instructions and workspace-relative paths. #815
  • Add --disable-nextest-doctest flag to cargo insta test to disable running doctests with nextest. Shows a deprecation warning when nextest is used with doctests without this flag, to prepare cargo insta to no longer run a separate doctest process when using nextest in the future. #803
  • Add ergonomic --test-runner-fallback / --no-test-runner-fallback flags to cargo insta test. #811
  • Apply redactions to snapshot metadata. #813
  • Remove confusing 'previously unseen snapshot' message. #812
  • Speed up JSON float rendering. #806 (@​nyurik)
  • Allow globset version up to 0.4.16. #810 (@​g0hl1n)
  • Improve documentation. #814 (@​tshepang)
  • We no longer trim starting newlines during assertions, which allows asserting the number of leading newlines match. Existing assertions with different leading newlines will pass and print a warning suggesting running with --force-update-snapshots. They may fail in the future. (Note that we still currently allow differing trailing newlines, though may adjust this in the future). #563
Commits
  • dcbb11f Prepare release 1.44.3 (#838)
  • 3b9ec12 Refine test name & description (#837)
  • ee4e1ea Handle unparsable snapshot files gracefully (#836)
  • 778f733 Fix for code before macros, such as let foo = assert_snapshot! (#835)
  • 6cb41af Prepare release 1.44.2 (#831)
  • 8838b2f Handle merge conflicts in snapshot files gracefully (#829)
  • e55ce99 Fix backward compatibility for legacy inline snapshot format (#830)
  • d44dd42 Skip nextest_doctest tests when cargo-nextest is not installed (#826)
  • a711baf Fix functional tests failing under nextest (#824)
  • ba9ea51 Prepare release 1.44.1 (#823)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=insta&package-manager=cargo&previous-version=1.43.2&new-version=1.44.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a3ad04346c..dd393a7029 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3399,9 +3399,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" dependencies = [ "console", "once_cell", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0587ec1d46..21408c240f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -139,7 +139,7 @@ icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } indexmap = "2.12.0" -insta = "1.43.2" +insta = "1.44.3" itertools = "0.14.0" keyring = { version = "3.6", default-features = false } landlock = "0.4.1" From 5e888ab48ecff65bb5425c78423026b68d94c78c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:13:51 +0000 Subject: [PATCH 076/159] chore(deps): bump wildmatch from 2.5.0 to 2.6.1 in /codex-rs (#7716) Bumps [wildmatch](https://github.com/becheran/wildmatch) from 2.5.0 to 2.6.1.
Commits
  • ca6568b chore: Release wildmatch version 2.6.1
  • 513c5ab docs: fix broken links
  • fe47b5f chore: use latest mlc version
  • 4d05f9f Merge pull request #30 from arifd/patch-1
  • 26114f7 unify example pattern used in WildMatchPattern examples
  • 32c36f5 chore: Release wildmatch version 2.6.0
  • 4777964 Merge pull request #29 from arifd/prevent-ambiguous-same-single-multi-wildcard
  • 3a5bf1b prevent ambiguous same single multi wildcard
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=wildmatch&package-manager=cargo&previous-version=2.5.0&new-version=2.6.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dd393a7029..3e7f0fd8b0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -7408,9 +7408,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] name = "winapi" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 21408c240f..2086fbe897 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -223,7 +223,7 @@ vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" which = "6" -wildmatch = "2.5.0" +wildmatch = "2.6.1" wiremock = "0.6" zeroize = "1.8.2" From cfda44b98bd4174c0cf609e4723d8c372547b2d1 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 09:35:03 -0800 Subject: [PATCH 077/159] fix wrap behavior for long commands (#7655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before: image after: Screenshot 2025-12-05 at 4 37 14 PM also removes `is_current`, which is deadcode --- codex-rs/tui/src/bottom_pane/command_popup.rs | 2 +- .../tui/src/bottom_pane/file_search_popup.rs | 2 +- .../src/bottom_pane/list_selection_view.rs | 53 +++++++++++++++-- .../src/bottom_pane/selection_popup_common.rs | 58 ++++++++++--------- codex-rs/tui/src/bottom_pane/skill_popup.rs | 2 +- 5 files changed, 83 insertions(+), 34 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 39bbfbd182..8aca5c4a62 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -182,9 +182,9 @@ impl CommandPopup { GenericDisplayRow { name, match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 708b004748..064e4f0137 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -129,9 +129,9 @@ impl WidgetRef for &FileSearchPopup { .indices .as_ref() .map(|v| v.iter().map(|&i| i as usize).collect()), - is_current: false, display_shortcut: None, description: None, + wrap_indent: None, }) .collect() }; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index d294a47265..b58524185b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -28,6 +28,7 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; +use unicode_width::UnicodeWidthStr; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; @@ -192,23 +193,26 @@ impl ListSelectionView { item.name.clone() }; let n = visible_idx + 1; - let display_name = if self.is_searchable { + let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the // numbers be used for the search query). - format!("{prefix} {name_with_marker}") + format!("{prefix} ") } else { - format!("{prefix} {n}. {name_with_marker}") + format!("{prefix} {n}. ") }; + let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); + let display_name = format!("{wrap_prefix}{name_with_marker}"); let description = is_selected .then(|| item.selected_description.clone()) .flatten() .or_else(|| item.description.clone()); + let wrap_indent = description.is_none().then_some(wrap_prefix_width); GenericDisplayRow { name: display_name, display_shortcut: item.display_shortcut, match_indices: None, - is_current: item.is_current, description, + wrap_indent, } }) }) @@ -558,6 +562,47 @@ mod tests { ); } + #[test] + fn wraps_long_option_without_overflowing_columns() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Yes, proceed".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Approval".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, 60); + let command_line = rendered + .lines() + .find(|line| line.contains("python -mpre_commit run")) + .expect("rendered lines should include wrapped command"); + assert!( + command_line.starts_with(" `python -mpre_commit run"), + "wrapped command line should align under the numbered prefix:\n{rendered}" + ); + assert!( + rendered.contains("eslint-plugin/no-") + && rendered.contains("mixed-const-enum-exports.js"), + "long command should not be truncated even when wrapped:\n{rendered}" + ); + } + #[test] fn width_changes_do_not_hide_rows() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 8042a75b28..5107ab0ca9 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -19,8 +19,8 @@ pub(crate) struct GenericDisplayRow { pub name: String, pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) - pub is_current: bool, - pub description: Option, // optional grey text after the name + pub description: Option, // optional grey text after the name + pub wrap_indent: Option, // optional indent for wrapped lines } /// Compute a shared description-column start based on the widest visible name @@ -47,13 +47,30 @@ fn compute_desc_col( desc_col } +/// Determine how many spaces to indent wrapped lines for a row. +fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { + let max_indent = max_width.saturating_sub(1) as usize; + let indent = row.wrap_indent.unwrap_or_else(|| { + if row.description.is_some() { + desc_col + } else { + 0 + } + }); + indent.min(max_indent) +} + /// Build the full display line for a row with the description padded to start /// at `desc_col`. Applies fuzzy-match bolding when indices are present and /// dims the description. fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { // Enforce single-line name: allow at most desc_col - 2 cells for name, // reserving two spaces before the description column. - let name_limit = desc_col.saturating_sub(2); + let name_limit = row + .description + .as_ref() + .map(|_| desc_col.saturating_sub(2)) + .unwrap_or(usize::MAX); let mut name_spans: Vec = Vec::with_capacity(row.name.len()); let mut used_width = 0usize; @@ -63,11 +80,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { let mut idx_iter = idxs.iter().peekable(); for (char_idx, ch) in row.name.chars().enumerate() { let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - if used_width + ch_w > name_limit { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); @@ -79,11 +97,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { } else { for ch in row.name.chars() { let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - if used_width + ch_w > name_limit { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; name_spans.push(ch.to_string().into()); } } @@ -161,24 +180,7 @@ pub(crate) fn render_rows( break; } - let GenericDisplayRow { - name, - match_indices, - display_shortcut, - is_current: _is_current, - description, - } = row; - - let mut full_line = build_full_line( - &GenericDisplayRow { - name: name.clone(), - match_indices: match_indices.clone(), - display_shortcut: *display_shortcut, - is_current: *_is_current, - description: description.clone(), - }, - desc_col, - ); + let mut full_line = build_full_line(row, desc_col); if Some(i) == state.selected_idx { // Match previous behavior: cyan + bold for the selected row. // Reset the style first to avoid inheriting dim from keyboard shortcuts. @@ -190,9 +192,10 @@ pub(crate) fn render_rows( // Wrap with subsequent indent aligned to the description column. use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; + let continuation_indent = wrap_indent(row, desc_col, area.width); let options = RtOptions::new(area.width as usize) .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); let wrapped = word_wrap_line(&full_line, options); // Render the wrapped lines. @@ -256,9 +259,10 @@ pub(crate) fn measure_rows_height( .map(|(_, r)| r) { let full_line = build_full_line(row, desc_col); + let continuation_indent = wrap_indent(row, desc_col, content_width); let opts = RtOptions::new(content_width as usize) .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); } total.max(1) diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 74c1b137ca..3e0f79f84b 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -90,9 +90,9 @@ impl SkillPopup { GenericDisplayRow { name, match_indices: indices, - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() From c2bdee094658fdbe82c15cd589f18e61b12e1997 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 09:55:20 -0800 Subject: [PATCH 078/159] proposing execpolicy amendment when prompting due to sandbox denial (#7653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, we only show the “don’t ask again for commands that start with…” option when a command is immediately flagged as needing approval. However, there is another case where we ask for approval: When a command is initially auto-approved to run within sandbox, but it fails to run inside sandbox, we would like to attempt to retry running outside of sandbox. This will require a prompt to the user. This PR addresses this latter case --- codex-rs/core/src/exec_policy.rs | 142 ++++++++++++++---- codex-rs/core/src/tools/runtimes/shell.rs | 3 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- codex-rs/core/src/tools/sandboxing.rs | 8 + 4 files changed, 121 insertions(+), 35 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2d1c2efe5e..6de2967c76 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -34,6 +34,13 @@ const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; +fn is_policy_match(rule_match: &RuleMatch) -> bool { + match rule_match { + RuleMatch::PrefixRuleMatch { .. } => true, + RuleMatch::HeuristicsRuleMatch { .. } => false, + } +} + #[derive(Debug, Error)] pub enum ExecPolicyError { #[error("failed to read execpolicy files from {dir}: {source}")] @@ -147,49 +154,62 @@ pub(crate) async fn append_execpolicy_amendment_and_update( Ok(()) } -/// Returns a proposed execpolicy amendment only when heuristics caused -/// the prompt decision, so we can offer to apply that amendment for future runs. -/// -/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit -/// execpolicy rule also prompts, we return `None` because applying the amendment would not -/// skip that policy requirement. -/// -/// Examples: +/// Derive a proposed execpolicy amendment when a command requires user approval +/// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. +/// - Otherwise return the first heuristics Prompt. +/// - Examples: /// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`. /// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`. /// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt /// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`. /// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above, /// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"]. -fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option { - if evaluation.decision != Decision::Prompt { +fn try_derive_execpolicy_amendment_for_prompt_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules + .iter() + .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt) + { return None; } - let mut first_prompt_from_heuristics: Option> = None; - for rule_match in &evaluation.matched_rules { - match rule_match { - RuleMatch::HeuristicsRuleMatch { command, decision } => { - if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { - first_prompt_from_heuristics = Some(command.clone()); - } - } - _ if rule_match.decision() == Decision::Prompt => { - return None; - } - _ => {} - } + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) +} + +/// - Note: we only use this amendment when the command fails to run in sandbox and codex prompts the user to run outside the sandbox +/// - The purpose of this amendment is to bypass sandbox for similar commands in the future +/// - If any execpolicy rule matches, return None, because we would already be running command outside the sandbox +fn try_derive_execpolicy_amendment_for_allow_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules.iter().any(is_policy_match) { + return None; } - first_prompt_from_heuristics.map(ExecPolicyAmendment::from) + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Allow, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) } /// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. fn derive_prompt_reason(evaluation: &Evaluation) -> Option { evaluation.matched_rules.iter().find_map(|rule_match| { - if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Prompt - { + if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt { Some(PROMPT_REASON.to_string()) } else { None @@ -215,10 +235,6 @@ pub(crate) async fn create_exec_approval_requirement_for_command( }; let policy = exec_policy.read().await; let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); - let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { - !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Allow - }); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -233,7 +249,7 @@ pub(crate) async fn create_exec_approval_requirement_for_command( ExecApprovalRequirement::NeedsApproval { reason: derive_prompt_reason(&evaluation), proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { - proposed_execpolicy_amendment(&evaluation) + try_derive_execpolicy_amendment_for_prompt_rules(&evaluation.matched_rules) } else { None }, @@ -241,7 +257,15 @@ pub(crate) async fn create_exec_approval_requirement_for_command( } } Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: has_policy_allow, + // Bypass sandbox if execpolicy allows the command + bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| { + is_policy_match(rule_match) && rule_match.decision() == Decision::Allow + }), + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules) + } else { + None + }, }, } } @@ -730,4 +754,56 @@ prefix_rule(pattern=["rm"], decision="forbidden") } ); } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); + } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { + let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); + } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 2af095ee92..50b6a6785a 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -133,7 +133,8 @@ impl Approvable for ShellRuntime { || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 4c1cbb83ec..d21e6de1e2 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -154,7 +154,8 @@ impl Approvable for UnifiedExecRuntime<'_> { || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 94c81043cc..5e69696923 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -95,6 +95,9 @@ pub(crate) enum ExecApprovalRequirement { /// The first attempt should skip sandboxing (e.g., when explicitly /// greenlit by policy). bypass_sandbox: bool, + /// Proposed execpolicy amendment to skip future approvals for similar commands + /// Only applies if the command fails to run in sandbox and codex prompts the user to run outside the sandbox. + proposed_execpolicy_amendment: Option, }, /// Approval required for this tool call. NeedsApproval { @@ -114,6 +117,10 @@ impl ExecApprovalRequirement { proposed_execpolicy_amendment: Some(prefix), .. } => Some(prefix), + Self::Skip { + proposed_execpolicy_amendment: Some(prefix), + .. + } => Some(prefix), _ => None, } } @@ -140,6 +147,7 @@ pub(crate) fn default_exec_approval_requirement( } else { ExecApprovalRequirement::Skip { bypass_sandbox: false, + proposed_execpolicy_amendment: None, } } } From da983c176116cf38920e3809f93a0345694fc597 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 18:42:09 +0000 Subject: [PATCH 079/159] feat: add is-mutating detection for shell command handler (#7729) --- codex-rs/core/src/tools/handlers/shell.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c3ef590e13..7d13c90fa0 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -148,6 +148,20 @@ impl ToolHandler for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| { + let shell = invocation.session.user_shell(); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + !is_known_safe_command(&command) + }) + .unwrap_or(true) + } + async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, From 585f75bd5aed80ee1766582a0a9c62ef14d1b934 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 8 Dec 2025 11:04:49 -0800 Subject: [PATCH 080/159] Make the device auth instructions more clear. (#7745) - [x] Make the device auth instructions more clear. --- codex-rs/login/src/device_code_auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index a121de7ebd..d9e7d90ce2 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -141,7 +141,7 @@ fn print_device_code_prompt(code: &str) { println!( "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ \nFollow these steps to sign in with ChatGPT using device code authorization:\n\ -\n1. Open this link in your browser\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ +\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ \n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\ \n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n", version = env!("CARGO_PKG_VERSION"), From 28e7218c0b7e76462d33eec4c7bdd18f48e1dc94 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 11:13:50 -0800 Subject: [PATCH 081/159] feat: linux codesign with sigstore (#7674) ### Summary Linux codesigning with sigstore and test run output at https://github.com/openai/codex/actions/runs/19994328162?pr=7662. Sigstore is one of the few ways for codesigning for linux platform. Linux is open sourced and therefore binary/dist validation comes with the build itself instead of a central authority like Windows or Mac. Alternative here is to use GPG which again a public key included with the bundle for validation. Advantage with Sigstore is that we do not have to create a private key for signing but rather with[ keyless signing](https://docs.sigstore.dev/cosign/signing/overview/). This should be sufficient for us at this point and if we want to we can support GPG in the future. --- .github/actions/linux-code-sign/action.yml | 44 ++++++++++++++++++++++ .github/workflows/rust-release.yml | 20 ++++++++++ 2 files changed, 64 insertions(+) create mode 100644 .github/actions/linux-code-sign/action.yml diff --git a/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml new file mode 100644 index 0000000000..5a117b0805 --- /dev/null +++ b/.github/actions/linux-code-sign/action.yml @@ -0,0 +1,44 @@ +name: linux-code-sign +description: Sign Linux artifacts with cosign. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + artifacts-dir: + description: Absolute path to the directory containing built binaries to sign. + required: true + +runs: + using: composite + steps: + - name: Install cosign + uses: sigstore/cosign-installer@v3.7.0 + + - name: Cosign Linux artifacts + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + COSIGN_YES: "true" + COSIGN_OIDC_CLIENT_ID: "sigstore" + COSIGN_OIDC_ISSUER: "https://oauth2.sigstore.dev/auth" + run: | + set -euo pipefail + + dest="${{ inputs.artifacts-dir }}" + if [[ ! -d "$dest" ]]; then + echo "Destination $dest does not exist" + exit 1 + fi + + for binary in codex codex-responses-api-proxy; do + artifact="${dest}/${binary}" + if [[ ! -f "$artifact" ]]; then + echo "Binary $artifact not found" + exit 1 + fi + + cosign sign-blob \ + --yes \ + --bundle "${artifact}.sigstore" \ + "$artifact" + done diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 14f8aa0327..c3e9eeef9a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -50,6 +50,9 @@ jobs: name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 + permissions: + contents: read + id-token: write defaults: run: working-directory: codex-rs @@ -100,6 +103,13 @@ jobs: - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + - if: ${{ contains(matrix.target, 'linux') }} + name: Cosign Linux artifacts + uses: ./.github/actions/linux-code-sign + with: + target: ${{ matrix.target }} + artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash @@ -283,6 +293,11 @@ jobs: cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi + if [[ "${{ matrix.target }}" == *linux* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" + fi + - if: ${{ matrix.runner == 'windows-11-arm' }} name: Install zstd shell: powershell @@ -321,6 +336,11 @@ jobs: continue fi + # Don't try to compress signature bundles. + if [[ "$base" == *.sigstore ]]; then + continue + fi + # Create per-binary tar.gz tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" From 4a3e9ed88d0d4a2624df8dfc97b600aac9b28b3e Mon Sep 17 00:00:00 2001 From: Takuto Yuki Date: Tue, 9 Dec 2025 04:21:15 +0900 Subject: [PATCH 082/159] fix(tui): add missing Ctrl+n/Ctrl+p support to ListSelectionView (#7629) ## Summary Extend Ctrl+n/Ctrl+p navigation support to selection popups (model picker, approval mode, etc.) This is a follow-up to #7530, which added Ctrl+n/Ctrl+p navigation to the textarea. The same keybindings were missing from `ListSelectionView`, causing inconsistent behavior when navigating selection popups. ## Related - #7530 - feat(tui): map Ctrl-P/N to arrow navigation in textarea ## Changes - Added Ctrl+n as alternative to Down arrow in selection popups - Added Ctrl+p as alternative to Up arrow in selection popups - Added unit tests for the new keybindings ## Test Plan - [x] `cargo test -p codex-tui list_selection_view` - all tests pass - [x] Manual testing: verified Ctrl+n/p navigation works in model selection popup --------- Co-authored-by: Eric Traut --- .../src/bottom_pane/list_selection_view.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index b58524185b..26a32a42e1 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -268,13 +268,36 @@ impl ListSelectionView { impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. KeyEvent { code: KeyCode::Up, .. - } => self.move_up(), + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^P */ => self.move_up(), KeyEvent { code: KeyCode::Down, .. - } => self.move_down(), + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^N */ => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. From 222a49157077d0010e57e48bf8ec4144c50702c4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 13:43:04 -0800 Subject: [PATCH 083/159] load models from disk and set a ttl and etag (#7722) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/codex-api/src/endpoint/models.rs | 77 ++++- .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/auth.rs | 18 +- codex-rs/core/src/conversation_manager.rs | 3 +- codex-rs/core/src/openai_models/cache.rs | 56 ++++ codex-rs/core/src/openai_models/mod.rs | 1 + .../core/src/openai_models/models_manager.rs | 278 +++++++++++++++--- codex-rs/core/tests/common/responses.rs | 9 +- codex-rs/core/tests/suite/remote_models.rs | 3 +- codex-rs/protocol/src/openai_models.rs | 2 + codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/chatwidget.rs | 20 +- codex-rs/tui/src/chatwidget/tests.rs | 8 +- 13 files changed, 414 insertions(+), 70 deletions(-) create mode 100644 codex-rs/core/src/openai_models/cache.rs diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index 39f7b30c32..5de08432f0 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -8,6 +8,7 @@ use codex_client::RequestTelemetry; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; use http::Method; +use http::header::ETAG; use std::sync::Arc; pub struct ModelsClient { @@ -59,12 +60,23 @@ impl ModelsClient { ) .await?; - serde_json::from_slice::(&resp.body).map_err(|e| { - ApiError::Stream(format!( - "failed to decode models response: {e}; body: {}", - String::from_utf8_lossy(&resp.body) - )) - }) + let header_etag = resp + .headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + + let ModelsResponse { models, etag } = serde_json::from_slice::(&resp.body) + .map_err(|e| { + ApiError::Stream(format!( + "failed to decode models response: {e}; body: {}", + String::from_utf8_lossy(&resp.body) + )) + })?; + + let etag = header_etag.unwrap_or(etag); + + Ok(ModelsResponse { models, etag }) } } @@ -86,20 +98,36 @@ mod tests { use std::sync::Mutex; use std::time::Duration; - #[derive(Clone, Default)] + #[derive(Clone)] struct CapturingTransport { last_request: Arc>>, body: Arc, } + impl Default for CapturingTransport { + fn default() -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(ModelsResponse { + models: Vec::new(), + etag: String::new(), + }), + } + } + } + #[async_trait] impl HttpTransport for CapturingTransport { async fn execute(&self, req: Request) -> Result { *self.last_request.lock().unwrap() = Some(req); let body = serde_json::to_vec(&*self.body).unwrap(); + let mut headers = HeaderMap::new(); + if !self.body.etag.is_empty() { + headers.insert(ETAG, self.body.etag.parse().unwrap()); + } Ok(Response { status: StatusCode::OK, - headers: HeaderMap::new(), + headers, body: body.into(), }) } @@ -138,7 +166,10 @@ mod tests { #[tokio::test] async fn appends_client_version_query() { - let response = ModelsResponse { models: Vec::new() }; + let response = ModelsResponse { + models: Vec::new(), + etag: String::new(), + }; let transport = CapturingTransport { last_request: Arc::new(Mutex::new(None)), @@ -191,6 +222,7 @@ mod tests { })) .unwrap(), ], + etag: String::new(), }; let transport = CapturingTransport { @@ -214,4 +246,31 @@ mod tests { assert_eq!(result.models[0].supported_in_api, true); assert_eq!(result.models[0].priority, 1); } + + #[tokio::test] + async fn list_models_includes_etag() { + let response = ModelsResponse { + models: Vec::new(), + etag: "\"abc\"".to_string(), + }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + DummyAuth, + ); + + let result = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(result.models.len(), 0); + assert_eq!(result.etag, "\"abc\""); + } } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index fff9c53f7a..6ef328188f 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -78,6 +78,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, upgrade: None, }], + etag: String::new(), }; Mock::given(method("GET")) diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 72359ca4ca..57ffa17260 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -32,7 +32,9 @@ use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; use codex_protocol::account::PlanType as AccountPlanType; +use once_cell::sync::Lazy; use serde_json::Value; +use tempfile::TempDir; use thiserror::Error; #[derive(Debug, Clone)] @@ -62,6 +64,8 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); + #[derive(Debug, Error)] pub enum RefreshTokenError { #[error("{0}")] @@ -1088,11 +1092,19 @@ impl AuthManager { } } + #[cfg(any(test, feature = "test-support"))] + #[expect(clippy::expect_used)] /// Create an AuthManager with a specific CodexAuth, for testing only. pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { let cached = CachedAuth { auth: Some(auth) }; + let temp_dir = tempfile::tempdir().expect("temp codex home"); + let codex_home = temp_dir.path().to_path_buf(); + TEST_AUTH_TEMP_DIRS + .lock() + .expect("lock test codex homes") + .push(temp_dir); Arc::new(Self { - codex_home: PathBuf::new(), + codex_home, inner: RwLock::new(cached), enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, @@ -1104,6 +1116,10 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + pub fn codex_home(&self) -> &Path { + &self.codex_home + } + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index e527507c1c..b1818849eb 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -51,6 +51,7 @@ impl ConversationManager { } } + #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { @@ -213,7 +214,7 @@ impl ConversationManager { } pub async fn list_models(&self) -> Vec { - self.models_manager.available_models.read().await.clone() + self.models_manager.list_models().await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/openai_models/cache.rs b/codex-rs/core/src/openai_models/cache.rs new file mode 100644 index 0000000000..cac16cc853 --- /dev/null +++ b/codex-rs/core/src/openai_models/cache.rs @@ -0,0 +1,56 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::openai_models::ModelInfo; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Duration; +use tokio::fs; + +/// Serialized snapshot of models and metadata cached on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ModelsCache { + pub(crate) fetched_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) etag: Option, + pub(crate) models: Vec, +} + +impl ModelsCache { + /// Returns `true` when the cache entry has not exceeded the configured TTL. + pub(crate) fn is_fresh(&self, ttl: Duration) -> bool { + if ttl.is_zero() { + return false; + } + let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else { + return false; + }; + let age = Utc::now().signed_duration_since(self.fetched_at); + age <= ttl_duration + } +} + +/// Read and deserialize the cache file if it exists. +pub(crate) async fn load_cache(path: &Path) -> io::Result> { + match fs::read(path).await { + Ok(contents) => { + let cache = serde_json::from_slice(&contents) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + Ok(Some(cache)) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +/// Persist the cache contents to disk, creating parent directories as needed. +pub(crate) async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + let json = serde_json::to_vec_pretty(cache) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + fs::write(path, json).await +} diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index e7a8beddb1..a77438ebc9 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1,3 +1,4 @@ +mod cache; pub mod model_family; pub mod model_presets; pub mod models_manager; diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 55c11f4554..d50844098e 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,11 +1,19 @@ +use chrono::Utc; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; +use tokio::sync::TryLockError; +use tracing::error; +use super::cache; +use super::cache::ModelsCache; use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::AuthManager; @@ -17,29 +25,41 @@ use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; +const MODEL_CACHE_FILE: &str = "models_cache.json"; +const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); + +/// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct - pub available_models: RwLock>, - pub remote_models: RwLock>, - pub etag: String, - pub auth_manager: Arc, + available_models: RwLock>, + remote_models: RwLock>, + auth_manager: Arc, + etag: RwLock>, + codex_home: PathBuf, + cache_ttl: Duration, } impl ModelsManager { + /// Construct a manager scoped to the provided `AuthManager`. pub fn new(auth_manager: Arc) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); Self { available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), remote_models: RwLock::new(Vec::new()), - etag: String::new(), auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, } } - pub async fn refresh_available_models( - &self, - provider: &ModelProviderInfo, - ) -> CoreResult> { + /// Fetch the latest remote models, using the on-disk cache when still fresh. + pub async fn refresh_available_models(&self, provider: &ModelProviderInfo) -> CoreResult<()> { + if self.try_load_cache().await { + return Ok(()); + } + let auth = self.auth_manager.auth(); let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; @@ -50,21 +70,30 @@ impl ModelsManager { if client_version == "0.0.0" { client_version = "99.99.99"; } - let response = client + let ModelsResponse { models, etag } = client .list_models(client_version, HeaderMap::new()) .await .map_err(map_api_error)?; - let models = response.models; - *self.remote_models.write().await = models.clone(); - let available_models = self.build_available_models().await; - { - let mut available_models_guard = self.available_models.write().await; - *available_models_guard = available_models; - } - Ok(models) + let etag = (!etag.is_empty()).then_some(etag); + + self.apply_remote_models(models.clone()).await; + *self.etag.write().await = etag.clone(); + self.persist_cache(&models, etag).await; + Ok(()) } + pub async fn list_models(&self) -> Vec { + self.available_models.read().await.clone() + } + + pub fn try_list_models(&self) -> Result, TryLockError> { + self.available_models + .try_read() + .map(|models| models.clone()) + } + + /// Look up the requested model family while applying remote metadata overrides. pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { find_family_for_model(model) .with_config_overrides(config) @@ -72,11 +101,55 @@ impl ModelsManager { } #[cfg(any(test, feature = "test-support"))] + /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } - async fn build_available_models(&self) -> Vec { + /// Replace the cached remote models and rebuild the derived presets list. + async fn apply_remote_models(&self, models: Vec) { + *self.remote_models.write().await = models; + self.build_available_models().await; + } + + /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. + async fn try_load_cache(&self) -> bool { + let cache_path = self.cache_path(); + let cache = match cache::load_cache(&cache_path).await { + Ok(cache) => cache, + Err(err) => { + error!("failed to load models cache: {err}"); + return false; + } + }; + let cache = match cache { + Some(cache) => cache, + None => return false, + }; + if !cache.is_fresh(self.cache_ttl) { + return false; + } + let models = cache.models.clone(); + *self.etag.write().await = cache.etag.clone(); + self.apply_remote_models(models.clone()).await; + true + } + + /// Serialize the latest fetch to disk for reuse across future processes. + async fn persist_cache(&self, models: &[ModelInfo], etag: Option) { + let cache = ModelsCache { + fetched_at: Utc::now(), + etag, + models: models.to_vec(), + }; + let cache_path = self.cache_path(); + if let Err(err) = cache::save_cache(&cache_path, &cache).await { + error!("failed to write models cache: {err}"); + } + } + + /// Convert remote model metadata into picker-ready presets, marking defaults. + async fn build_available_models(&self) { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); let mut model_presets: Vec = available_models @@ -87,22 +160,29 @@ impl ModelsManager { if let Some(default) = model_presets.first_mut() { default.is_default = true; } - model_presets + { + let mut available_models_guard = self.available_models.write().await; + *available_models_guard = model_presets; + } + } + + fn cache_path(&self) -> PathBuf { + self.codex_home.join(MODEL_CACHE_FILE) } } #[cfg(test)] mod tests { + use super::cache::ModelsCache; use super::*; use crate::CodexAuth; + use crate::auth::AuthCredentialsStoreMode; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; + use core_test_support::responses::mount_models_once; use serde_json::json; - use wiremock::Mock; + use tempfile::tempdir; use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { serde_json::from_value(json!({ @@ -146,35 +226,28 @@ mod tests { remote_model("priority-low", "Low", 1), remote_model("priority-high", "High", 10), ]; - let response = ModelsResponse { - models: remote_models.clone(), - }; - Mock::given(method("GET")) - .and(path("/models")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(&response), - ) - .expect(1) - .mount(&server) - .await; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); - let returned = manager + manager .refresh_available_models(&provider) .await .expect("refresh succeeds"); - - assert_eq!(returned, remote_models); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.available_models.read().await.clone(); + let available = manager.list_models().await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -183,5 +256,128 @@ mod tests { ); assert_eq!(available[1].model, "priority-low"); assert!(!available[1].is_default); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_uses_cache_when_fresh() { + let server = MockServer::start().await; + let remote_models = vec![remote_model("cached", "Cached", 5)]; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let manager = ModelsManager::new(auth_manager); + let provider = provider_for(server.uri()); + + manager + .refresh_available_models(&provider) + .await + .expect("first refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "remote cache should store fetched models" + ); + + // Second call should read from cache and avoid the network. + manager + .refresh_available_models(&provider) + .await + .expect("cached refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "cache path should not mutate stored models" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "cache hit should avoid a second /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_refetches_when_cache_stale() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("stale", "Stale", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let manager = ModelsManager::new(auth_manager); + let provider = provider_for(server.uri()); + + manager + .refresh_available_models(&provider) + .await + .expect("initial refresh succeeds"); + + // Rewrite cache with an old timestamp so it is treated as stale. + let cache_path = codex_home.path().join(MODEL_CACHE_FILE); + let contents = + std::fs::read_to_string(&cache_path).expect("cache file should exist after refresh"); + let mut cache: ModelsCache = + serde_json::from_str(&contents).expect("cache should deserialize"); + cache.fetched_at = Utc::now() - chrono::Duration::hours(1); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap()) + .expect("cache rewrite succeeds"); + + let updated_models = vec![remote_model("fresh", "Fresh", 9)]; + server.reset().await; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: updated_models.clone(), + etag: String::new(), + }, + ) + .await; + + manager + .refresh_available_models(&provider) + .await + .expect("second refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + updated_models, + "stale cache should trigger refetch" + ); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "stale cache refresh should fetch /models once" + ); } } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index e42b4ac943..c67daeda87 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -677,7 +677,14 @@ pub async fn start_mock_server() -> MockServer { .await; // Provide a default `/models` response so tests remain hermetic when the client queries it. - let _ = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + let _ = mount_models_once( + &server, + ModelsResponse { + models: Vec::new(), + etag: String::new(), + }, + ) + .await; server } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 4178ed1c2a..b13188d5d1 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -73,6 +73,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { &server, ModelsResponse { models: vec![remote_model], + etag: String::new(), }, ) .await; @@ -170,7 +171,7 @@ async fn wait_for_model_available(manager: &Arc, slug: &str) -> M let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager.available_models.read().await; + let guard = manager.list_models().await; guard.iter().find(|model| model.model == slug).cloned() } { return model; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 0804811a3f..942303a902 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -141,6 +141,8 @@ pub struct ModelInfo { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] pub struct ModelsResponse { pub models: Vec, + #[serde(default)] + pub etag: String, } fn default_visibility() -> ModelVisibility { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 10d11d0535..06fb2a83e1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -127,7 +127,7 @@ async fn handle_model_migration_prompt_if_needed( auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.available_models.read().await.clone(); + let available_models = models_manager.list_models().await; let upgrade = available_models .iter() .find(|preset| preset.model == config.model) @@ -139,12 +139,12 @@ async fn handle_model_migration_prompt_if_needed( migration_config_key, }) = upgrade { - if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key) { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { return None; } let target_model = target_model.to_string(); - let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( &config.model, &target_model, @@ -154,7 +154,7 @@ async fn handle_model_migration_prompt_if_needed( return None; } - let prompt_copy = migration_copy_for_config(migration_config_key); + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); match run_model_migration_prompt(tui, prompt_copy).await { ModelMigrationOutcome::Accepted => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 41fe181b33..2ddab1626a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2053,7 +2053,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.available_models.try_read().ok()?; + let models = self.models_manager.try_list_models().ok()?; models .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) @@ -2162,14 +2162,16 @@ impl ChatWidget { let current_model = self.config.model.clone(); let presets: Vec = // todo(aibrahim): make this async function - if let Ok(models) = self.models_manager.available_models.try_read() { - models.clone() - } else { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - None, - ); - return; + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } }; let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 229e075e7f..cebcd05d5a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -956,9 +956,11 @@ fn active_blob(chat: &ChatWidget) -> String { } fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { - chat.models_manager - .available_models - .blocking_read() + let models = chat + .models_manager + .try_list_models() + .expect("models lock available"); + models .iter() .find(|&preset| preset.model == model) .cloned() From 0a32acaa2deb8883e9dcbfb80e84957837d39b74 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 8 Dec 2025 13:56:22 -0800 Subject: [PATCH 084/159] updating app server types to support execpoilcy amendment (#7747) also includes minor refactor merging `ApprovalDecision` with `CommandExecutionRequestAcceptSettings` --- .../app-server-protocol/src/protocol/v2.rs | 42 +++++++++++++------ codex-rs/app-server-test-client/src/main.rs | 6 ++- .../app-server/src/bespoke_event_handling.rs | 38 ++++++++++------- .../app-server/tests/suite/v2/turn_start.rs | 1 - 4 files changed, 57 insertions(+), 30 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 35d7047661..ea70b805b0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; @@ -287,6 +288,11 @@ v2_enum_from_core!( #[ts(export_to = "v2/")] pub enum ApprovalDecision { Accept, + /// Approve and remember the approval for the session. + AcceptForSession, + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, Decline, Cancel, } @@ -382,6 +388,27 @@ impl From for SandboxCommandAssessment { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -1468,15 +1495,8 @@ pub struct CommandExecutionRequestApprovalParams { pub reason: Option, /// Optional model-provided risk assessment describing the blocked command. pub risk: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestAcceptSettings { - /// If true, automatically approve this command for the duration of the session. - #[serde(default)] - pub for_session: bool, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + pub proposed_execpolicy_amendment: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1484,10 +1504,6 @@ pub struct CommandExecutionRequestAcceptSettings { #[ts(export_to = "v2/")] pub struct CommandExecutionRequestApprovalResponse { pub decision: ApprovalDecision, - /// Optional approval settings for when the decision is `accept`. - /// Ignored if the decision is `decline` or `cancel`. - #[serde(default)] - pub accept_settings: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 8c2a38e46c..92255cecd3 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -21,7 +21,6 @@ use codex_app_server_protocol::ApprovalDecision; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::CommandExecutionRequestAcceptSettings; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -754,6 +753,7 @@ impl CodexClient { item_id, reason, risk, + proposed_execpolicy_amendment, } = params; println!( @@ -765,10 +765,12 @@ impl CodexClient { if let Some(risk) = risk.as_ref() { println!("< risk assessment: {risk:?}"); } + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { + println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); + } let response = CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Accept, - accept_settings: Some(CommandExecutionRequestAcceptSettings { for_session: false }), }; self.send_server_request_response(request_id, &response)?; println!("< approved commandExecution request for item {item_id}"); diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 94676999b5..2fda7bcf58 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; +use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; @@ -179,7 +180,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, - proposed_execpolicy_amendment: _, + proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { ApiVersion::V1 => { @@ -207,6 +208,8 @@ pub(crate) async fn apply_bespoke_event_handling( .map(V2ParsedCommand::from) .collect::>(); let command_string = shlex_join(&command); + let proposed_execpolicy_amendment_v2 = + proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -216,6 +219,7 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: item_id.clone(), reason, risk: risk.map(V2SandboxCommandAssessment::from), + proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing .send_request(ServerRequestPayload::CommandExecutionRequestApproval( @@ -1047,7 +1051,11 @@ async fn on_file_change_request_approval_response( }); let (decision, completion_status) = match response.decision { - ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::Accept + | ApprovalDecision::AcceptForSession + | ApprovalDecision::AcceptWithExecpolicyAmendment { .. } => { + (ReviewDecision::Approved, None) + } ApprovalDecision::Decline => { (ReviewDecision::Denied, Some(PatchApplyStatus::Declined)) } @@ -1109,25 +1117,27 @@ async fn on_command_execution_request_approval_response( error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, } }); - let CommandExecutionRequestApprovalResponse { - decision, - accept_settings, - } = response; + let decision = response.decision; - let (decision, completion_status) = match (decision, accept_settings) { - (ApprovalDecision::Accept, Some(settings)) if settings.for_session => { - (ReviewDecision::ApprovedForSession, None) - } - (ApprovalDecision::Accept, _) => (ReviewDecision::Approved, None), - (ApprovalDecision::Decline, _) => ( + let (decision, completion_status) = match decision { + ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None), + ApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + None, + ), + ApprovalDecision::Decline => ( ReviewDecision::Denied, Some(CommandExecutionStatus::Declined), ), - (ApprovalDecision::Cancel, _) => ( + ApprovalDecision::Cancel => ( ReviewDecision::Abort, Some(CommandExecutionStatus::Declined), ), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e4cd722947..afc22c7072 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -427,7 +427,6 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { request_id, serde_json::to_value(CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, })?, ) .await?; From 71c75e648c57404568c8a11a687694d14a44f42e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 14:22:51 -0800 Subject: [PATCH 085/159] Enhance model picker (#7709) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/tui/src/app.rs | 33 +++--- codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/chatwidget.rs | 159 +++++++++++++++++++++------ codex-rs/tui/src/chatwidget/tests.rs | 2 +- 4 files changed, 154 insertions(+), 45 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 06fb2a83e1..0a09b15e7c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -700,6 +700,9 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::OpenAllModelsPopup { models } => { + self.chat_widget.open_all_models_popup(models); + } AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } @@ -799,20 +802,17 @@ impl App { .await { Ok(()) => { - let reasoning_label = Self::reasoning_label(effort); - if let Some(profile) = profile { - self.chat_widget.add_info_message( - format!( - "Model changed to {model} {reasoning_label} for {profile} profile" - ), - None, - ); - } else { - self.chat_widget.add_info_message( - format!("Model changed to {model} {reasoning_label}"), - None, - ); + let mut message = format!("Model changed to {model}"); + if let Some(label) = Self::reasoning_label_for(&model, effort) { + message.push(' '); + message.push_str(label); } + if let Some(profile) = profile { + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); + } + self.chat_widget.add_info_message(message, None); } Err(err) => { tracing::error!( @@ -1012,6 +1012,13 @@ impl App { } } + fn reasoning_label_for( + model: &str, + reasoning_effort: Option, + ) -> Option<&'static str> { + (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) + } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3a199593bb..c92dab4b3a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -74,6 +74,11 @@ pub(crate) enum AppEvent { model: ModelPreset, }, + /// Open the full model picker (non-auto models). + OpenAllModelsPopup { + models: Vec, + }, + /// Open the confirmation prompt before enabling full access mode. OpenFullAccessConfirmation { preset: ApprovalPreset, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2ddab1626a..c8f221de68 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2156,8 +2156,8 @@ impl ChatWidget { }); } - /// Open a popup to choose the model (stage 1). After selecting a model, - /// a second popup is shown to choose the reasoning effort. + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); let presets: Vec = @@ -2174,13 +2174,103 @@ impl ChatWidget { } }; + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + ); + SelectionItem { + name: preset.display_name, + description, + is_current: model == current_model, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model".to_string()), + subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + None, + ); + return; + } + + let current_model = self.config.model.clone(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { - let description = if preset.description.is_empty() { - None - } else { - Some(preset.description.to_string()) - }; + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); let is_current = preset.model == current_model; let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); @@ -2212,6 +2302,36 @@ impl ChatWidget { }); } + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + ) -> Vec { + vec![Box::new(move |tx| { + let effort_label = effort_for_action + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_for_action.clone()), + effort: Some(effort_for_action), + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model_for_action, + effort_label + ); + })] + } + /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; @@ -2320,30 +2440,7 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let effort_for_action = choice.stored; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - })); - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model_for_action, - effort_for_action - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); - })]; + let actions = Self::model_selection_actions(model_for_action, choice.stored); items.push(SelectionItem { name: effort_label, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cebcd05d5a..5159d12ce0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1961,7 +1961,7 @@ fn reasoning_popup_escape_returns_to_model_popup() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let after_escape = render_bottom_popup(&chat, 80); - assert!(after_escape.contains("Select Model and Effort")); + assert!(after_escape.contains("Select Model")); assert!(!after_escape.contains("Select Reasoning Level")); } From a9f566af7bfb43c126dd6930b5aa8267b19f439c Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 8 Dec 2025 14:33:00 -0800 Subject: [PATCH 086/159] Restore status header after stream recovery (#7660) ## Summary - restore the previous status header when a non-error event arrives after a stream retry - add a regression test to ensure the reconnect banner clears once streaming resumes ## Testing - cargo fmt -- --config imports_granularity=Item - cargo clippy --fix --all-features --tests --allow-dirty -p codex-tui - NO_COLOR=0 cargo test -p codex-tui *(fails: vt100 color assertion tests expect colored cells but the environment returns Default colors even with NO_COLOR cleared and TERM/COLORTERM set)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69337f8c77508329b3ea85134d4a7ac7) --- codex-rs/tui/src/chatwidget.rs | 13 +++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8f221de68..1302b2343d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -377,6 +377,14 @@ impl ChatWidget { self.bottom_pane.update_status_header(header); } + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + self.set_status_header(header); + } + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane @@ -1771,6 +1779,11 @@ impl ChatWidget { /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { + let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); + if !is_stream_error { + self.restore_retry_status_header_if_present(); + } + match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5159d12ce0..126c91f9d8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2905,6 +2905,39 @@ fn warning_event_adds_warning_history_cell() { ); } +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "retry".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "delta".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.retry_status_header.is_none()); +} + #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); From cb45139244d57d6af64084df33da76a0fbc3ab99 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 14:52:39 -0800 Subject: [PATCH 087/159] Add formatting client version to the `x.x.x` style. (#7711) To avoid regression with special builds like alphas --- .../core/src/openai_models/models_manager.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index d50844098e..9ebf0112ad 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -66,12 +66,9 @@ impl ModelsManager { let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); - let mut client_version = env!("CARGO_PKG_VERSION"); - if client_version == "0.0.0" { - client_version = "99.99.99"; - } + let client_version = format_client_version_to_whole(); let ModelsResponse { models, etag } = client - .list_models(client_version, HeaderMap::new()) + .list_models(&client_version, HeaderMap::new()) .await .map_err(map_api_error)?; @@ -171,6 +168,28 @@ impl ModelsManager { } } +/// Convert a client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3") +fn format_client_version_to_whole() -> String { + format_client_version_from_parts( + env!("CARGO_PKG_VERSION_MAJOR"), + env!("CARGO_PKG_VERSION_MINOR"), + env!("CARGO_PKG_VERSION_PATCH"), + ) +} + +fn format_client_version_from_parts(major: &str, minor: &str, patch: &str) -> String { + const DEV_VERSION: &str = "0.0.0"; + const FALLBACK_VERSION: &str = "99.99.99"; + + let normalized = format!("{major}.{minor}.{patch}"); + + if normalized == DEV_VERSION { + FALLBACK_VERSION.to_string() + } else { + normalized + } +} + #[cfg(test)] mod tests { use super::cache::ModelsCache; From badda736c6a086a3b6a5766ea471cd9a2616f235 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 15:12:01 -0800 Subject: [PATCH 088/159] feat: windows codesign with Azure trusted signing (#7675) ### Summary Set up codesign for windows dist with [Azure trusted signing](https://azure.microsoft.com/en-us/products/trusted-signing) and [its github action integration](https://github.com/Azure/trusted-signing-action). --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 0000000000..17a4fbf999 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a..b90f0027fa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From ac5fa6baf8898fba30d1f7ad6bea2da986e3fc93 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 8 Dec 2025 15:23:02 -0800 Subject: [PATCH 089/159] Do not emit start/end events for write stdin (#7561) --- .../core/src/tools/handlers/unified_exec.rs | 1 - codex-rs/core/src/unified_exec/mod.rs | 2 - .../core/src/unified_exec/session_manager.rs | 51 +------ codex-rs/core/tests/suite/unified_exec.rs | 136 +++--------------- 4 files changed, 19 insertions(+), 171 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f2500a413b..66cf624a6c 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -215,7 +215,6 @@ impl ToolHandler for UnifiedExecHandler { })?; manager .write_stdin(WriteStdinRequest { - call_id: &call_id, process_id: &args.session_id.to_string(), input: &args.chars, yield_time_ms: args.yield_time_ms, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 34b62df342..02a0f9ead7 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -80,7 +80,6 @@ pub(crate) struct ExecCommandRequest { #[derive(Debug)] pub(crate) struct WriteStdinRequest<'a> { - pub call_id: &'a str, pub process_id: &'a str, pub input: &'a str, pub yield_time_ms: u64, @@ -216,7 +215,6 @@ mod tests { .services .unified_exec_manager .write_stdin(WriteStdinRequest { - call_id: "write-stdin", process_id, input, yield_time_ms, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 88d65ca142..af706b4b23 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -24,7 +24,6 @@ use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventFailure; use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; @@ -77,7 +76,6 @@ struct PreparedSessionHandles { session_ref: Arc, turn_ref: Arc, command: Vec, - cwd: PathBuf, process_id: String, } @@ -234,41 +232,12 @@ impl UnifiedExecSessionManager { session_ref, turn_ref, command: session_command, - cwd: session_cwd, process_id, + .. } = self.prepare_session_handles(process_id.as_str()).await?; - let interaction_emitter = ToolEmitter::unified_exec( - &session_command, - session_cwd.clone(), - ExecCommandSource::UnifiedExecInteraction, - (!request.input.is_empty()).then(|| request.input.to_string()), - Some(process_id.clone()), - ); - let make_event_ctx = || { - ToolEventCtx::new( - session_ref.as_ref(), - turn_ref.as_ref(), - request.call_id, - None, - ) - }; - interaction_emitter - .emit(make_event_ctx(), ToolEventStage::Begin) - .await; - if !request.input.is_empty() { - if let Err(err) = Self::send_input(&writer_tx, request.input.as_bytes()).await { - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Failure(ToolEventFailure::Message(format!( - "write_stdin failed: {err:?}" - ))), - ) - .await; - return Err(err); - } + Self::send_input(&writer_tx, request.input.as_bytes()).await?; tokio::time::sleep(Duration::from_millis(100)).await; } @@ -319,21 +288,6 @@ impl UnifiedExecSessionManager { session_command: Some(session_command.clone()), }; - let interaction_output = ExecToolCallOutput { - exit_code: response.exit_code.unwrap_or(0), - stdout: StreamOutput::new(response.output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(response.output.clone()), - duration: response.wall_time, - timed_out: false, - }; - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Success(interaction_output), - ) - .await; - if response.process_id.is_some() { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } @@ -400,7 +354,6 @@ impl UnifiedExecSessionManager { session_ref: Arc::clone(&entry.session_ref), turn_ref: Arc::clone(&entry.turn_ref), command: entry.command.clone(), - cwd: entry.cwd.clone(), process_id: entry.process_id.clone(), }) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 33e469fc1a..6a62e35dfb 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -765,104 +765,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_sandbox!(Ok(())); - skip_if_windows!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); - }); - let TestCodex { - codex, - cwd, - session_configured, - .. - } = builder.build(&server).await?; - - let open_call_id = "uexec-open-for-begin"; - let open_args = json!({ - "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 200, - }); - - let stdin_call_id = "uexec-stdin-begin"; - let stdin_args = json!({ - "chars": "echo hello", - "session_id": 1000, - "yield_time_ms": 400, - }); - - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - open_call_id, - "exec_command", - &serde_json::to_string(&open_args)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - stdin_call_id, - "write_stdin", - &serde_json::to_string(&stdin_args)?, - ), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-3"), - ]), - ]; - mount_sse_sequence(&server, responses).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "begin events for stdin".into(), - }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - model: session_model, - effort: None, - summary: ReasoningSummary::Auto, - }) - .await?; - - let begin_event = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandBegin(ev) if ev.call_id == stdin_call_id => Some(ev.clone()), - _ => None, - }) - .await; - - assert_command(&begin_event.command, "-lc", "bash -i"); - assert_eq!( - begin_event.interaction_input, - Some("echo hello".to_string()) - ); - assert_eq!( - begin_event.source, - ExecCommandSource::UnifiedExecInteraction - ); - - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> { +async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -883,8 +786,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> let open_call_id = "uexec-open-session"; let open_args = json!({ "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 250, + "cmd": "sleep 0.1".to_string(), + "yield_time_ms": 10, }); let poll_call_id = "uexec-poll-empty"; @@ -939,10 +842,12 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> .await?; let mut begin_events = Vec::new(); + let mut end_events = Vec::new(); loop { let event_msg = wait_for_event(&codex, |_| true).await; match event_msg { EventMsg::ExecCommandBegin(event) => begin_events.push(event), + EventMsg::ExecCommandEnd(event) => end_events.push(event), EventMsg::TaskComplete(_) => break, _ => {} } @@ -950,16 +855,19 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> assert_eq!( begin_events.len(), - 2, - "expected begin events for the startup command and the write_stdin call" + 1, + "expected begin events for the startup command" ); - let open_event = begin_events - .iter() - .find(|ev| ev.call_id == open_call_id) - .expect("missing exec_command begin"); + assert_eq!( + end_events.len(), + 1, + "expected end event for the write_stdin call" + ); - assert_command(&open_event.command, "-lc", "bash -i"); + let open_event = &begin_events[0]; + + assert_command(&open_event.command, "-lc", "sleep 0.1"); assert!( open_event.interaction_input.is_none(), @@ -967,18 +875,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> ); assert_eq!(open_event.source, ExecCommandSource::UnifiedExecStartup); - let poll_event = begin_events - .iter() - .find(|ev| ev.call_id == poll_call_id) - .expect("missing write_stdin begin"); - - assert_command(&poll_event.command, "-lc", "bash -i"); - - assert!( - poll_event.interaction_input.is_none(), - "poll begin events should omit interaction input" - ); - assert_eq!(poll_event.source, ExecCommandSource::UnifiedExecInteraction); + let end_event = &end_events[0]; + assert_eq!(end_event.call_id, open_call_id); Ok(()) } From 382f047a1064742767f84e9c551ea3c6df59d6dd Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 15:29:37 -0800 Subject: [PATCH 090/159] Remove legacy `ModelInfo` and merge it with `ModelFamily` (#7748) This is a step towards removing the need to know `model` when constructing config. We firstly don't need to know `model_info` and just respect if the user has already set it. Next step, we don't need to know `model` unless the user explicitly set it in `config.toml` --- codex-rs/core/src/client.rs | 13 +- codex-rs/core/src/codex.rs | 13 +- codex-rs/core/src/config/mod.rs | 35 ++-- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/openai_model_info.rs | 83 -------- .../core/src/openai_models/model_family.rs | 58 +++++- codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui/src/chatwidget/tests.rs | 183 +++++++++--------- codex-rs/tui/src/status/card.rs | 6 +- codex-rs/tui/src/status/tests.rs | 35 ++++ 10 files changed, 205 insertions(+), 225 deletions(-) delete mode 100644 codex-rs/core/src/openai_model_info.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4c3cf737b2..d4a714cdd5 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -48,7 +48,6 @@ use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use crate::openai_model_info::get_model_info; use crate::openai_models::model_family::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; @@ -95,19 +94,11 @@ impl ModelClient { pub fn get_model_context_window(&self) -> Option { let model_family = self.get_model_family(); let effective_context_window_percent = model_family.effective_context_window_percent; - self.config - .model_context_window - .or_else(|| get_model_info(&model_family).map(|info| info.context_window)) + model_family + .context_window .map(|w| w.saturating_mul(effective_context_window_percent) / 100) } - pub fn get_auto_compact_token_limit(&self) -> Option { - let model_family = self.get_model_family(); - self.config.model_auto_compact_token_limit.or_else(|| { - get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit) - }) - } - pub fn config(&self) -> Arc { Arc::clone(&self.config) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c33904e2fd..74ef11c323 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -80,7 +80,6 @@ use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; -use crate::openai_model_info::get_model_info; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; @@ -415,15 +414,11 @@ impl Session { otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, - mut per_turn_config: Config, + per_turn_config: Config, model_family: ModelFamily, conversation_id: ConversationId, sub_id: String, ) -> TurnContext { - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), model_family.slug.as_str(), @@ -1955,9 +1950,6 @@ async fn spawn_review_thread( per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } let otel_event_manager = parent_turn_context .client @@ -2097,7 +2089,8 @@ pub(crate) async fn run_task( } = turn_output; let limit = turn_context .client - .get_auto_compact_token_limit() + .get_model_family() + .auto_compact_token_limit() .unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= limit; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1cc46cf23..df7637a301 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -26,8 +26,6 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; -use crate::openai_model_info::get_model_info; -use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; @@ -1106,23 +1104,12 @@ impl Config { let forced_login_method = cfg.forced_login_method; + // todo(aibrahim): make model optional let model = model .or(config_profile.model) .or(cfg.model) .unwrap_or_else(default_model); - let model_family = find_family_for_model(&model); - - let openai_model_info = get_model_info(&model_family); - let model_context_window = cfg - .model_context_window - .or_else(|| openai_model_info.as_ref().map(|info| info.context_window)); - let model_auto_compact_token_limit = cfg.model_auto_compact_token_limit.or_else(|| { - openai_model_info - .as_ref() - .and_then(|info| info.auto_compact_token_limit) - }); - let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { @@ -1168,8 +1155,8 @@ impl Config { let config = Self { model, review_model, - model_context_window, - model_auto_compact_token_limit, + model_context_window: cfg.model_context_window, + model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, model_provider_id, model_provider, cwd: resolved_cwd, @@ -2950,8 +2937,8 @@ model_verbosity = "high" Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -3025,8 +3012,8 @@ model_verbosity = "high" let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(16_385), - model_auto_compact_token_limit: Some(14_746), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, @@ -3115,8 +3102,8 @@ model_verbosity = "high" let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3191,8 +3178,8 @@ model_verbosity = "high" let expected_gpt5_profile_config = Config { model: "gpt-5.1".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(272_000), - model_auto_compact_token_limit: Some(244_800), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 721c6bb43c..59dac84d26 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -67,7 +67,6 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; -mod openai_model_info; pub mod project_doc; mod rollout; pub(crate) mod safety; diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs deleted file mode 100644 index 4ee7d7187a..0000000000 --- a/codex-rs/core/src/openai_model_info.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::openai_models::model_family::ModelFamily; - -// Shared constants for commonly used window/token sizes. -pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; - -/// Metadata about a model, particularly OpenAI models. -/// We may want to consider including details like the pricing for -/// input tokens, output tokens, etc., though users will need to be able to -/// override this in config.toml, as this information can get out of date. -/// Though this would help present more accurate pricing information in the UI. -#[derive(Debug)] -pub(crate) struct ModelInfo { - /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: i64, - - /// Token threshold where we should automatically compact conversation history. This considers - /// input tokens + output tokens of this turn. - pub(crate) auto_compact_token_limit: Option, -} - -impl ModelInfo { - const fn new(context_window: i64) -> Self { - Self { - context_window, - auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), - } - } - - const fn default_auto_compact_limit(context_window: i64) -> i64 { - (context_window * 9) / 10 - } -} - -pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { - let slug = model_family.slug.as_str(); - match slug { - // OSS models have a 128k shared token pool. - // Arbitrarily splitting it: 3/4 input context, 1/4 output. - // https://openai.com/index/gpt-oss-model-card/ - "gpt-oss-20b" => Some(ModelInfo::new(96_000)), - "gpt-oss-120b" => Some(ModelInfo::new(96_000)), - // https://platform.openai.com/docs/models/o3 - "o3" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/o4-mini - "o4-mini" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/codex-mini-latest - "codex-mini-latest" => Some(ModelInfo::new(200_000)), - - // As of Jun 25, 2025, gpt-4.1 defaults to gpt-4.1-2025-04-14. - // https://platform.openai.com/docs/models/gpt-4.1 - "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo::new(1_047_576)), - - // As of Jun 25, 2025, gpt-4o defaults to gpt-4o-2024-08-06. - // https://platform.openai.com/docs/models/gpt-4o - "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-05-13 - "gpt-4o-2024-05-13" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-11-20 - "gpt-4o-2024-11-20" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-3.5-turbo - "gpt-3.5-turbo" => Some(ModelInfo::new(16_385)), - - _ if slug.starts_with("gpt-5-codex") - || slug.starts_with("gpt-5.1-codex") - || slug.starts_with("gpt-5.1-codex-max") => - { - Some(ModelInfo::new(CONTEXT_WINDOW_272K)) - } - - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("exp-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ => None, - } -} diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 507e1a48d9..6665165ee5 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -15,6 +15,7 @@ const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md"); const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -23,14 +24,20 @@ pub struct ModelFamily { /// "gpt-4.1-2025-04-14". pub slug: String, - /// The model family name, e.g. "gpt-4.1". Note this should able to be used - /// with [`crate::openai_model_info::get_model_info`]. + /// The model family name, e.g. "gpt-4.1". This string is used when deriving + /// default metadata for the family, such as context windows. pub family: String, /// True if the model needs additional instructions on how to use the /// "virtual" `apply_patch` CLI. pub needs_special_apply_patch_instructions: bool, + /// Maximum supported context window, if known. + pub context_window: Option, + + /// Token threshold for automatic compaction if config does not override it. + auto_compact_token_limit: Option, + // Whether the `reasoning` field can be set when making a request to this // model family. Note it has `effort` and `summary` subfields (though // `summary` is optional). @@ -82,6 +89,12 @@ impl ModelFamily { if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { self.reasoning_summary_format = reasoning_summary_format.clone(); } + if let Some(context_window) = config.model_context_window { + self.context_window = Some(context_window); + } + if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { + self.auto_compact_token_limit = Some(auto_compact_token_limit); + } self } pub fn with_remote_overrides(mut self, remote_models: Vec) -> Self { @@ -93,6 +106,15 @@ impl ModelFamily { } self } + + pub fn auto_compact_token_limit(&self) -> Option { + self.auto_compact_token_limit + .or(self.context_window.map(Self::default_auto_compact_limit)) + } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } } macro_rules! model_family { @@ -105,6 +127,8 @@ macro_rules! model_family { slug: $slug.to_string(), family: $family.to_string(), needs_special_apply_patch_instructions: false, + context_window: Some(CONTEXT_WINDOW_272K), + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, @@ -136,12 +160,14 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { slug, "o3", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("o4-mini") { model_family!( slug, "o4-mini", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("codex-mini-latest") { model_family!( @@ -149,18 +175,32 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, shell_type: ConfigShellToolType::Local, + context_window: Some(200_000), ) } else if slug.starts_with("gpt-4.1") { model_family!( slug, "gpt-4.1", needs_special_apply_patch_instructions: true, + context_window: Some(1_047_576), ) } else if slug.starts_with("gpt-oss") || slug.starts_with("openai/gpt-oss") { - model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function)) + model_family!( + slug, "gpt-oss", + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + context_window: Some(96_000), + ) } else if slug.starts_with("gpt-4o") { - model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-4o", + needs_special_apply_patch_instructions: true, + context_window: Some(128_000), + ) } else if slug.starts_with("gpt-3.5") { - model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-3.5", + needs_special_apply_patch_instructions: true, + context_window: Some(16_385), + ) } else if slug.starts_with("test-gpt-5") { model_family!( slug, slug, @@ -196,6 +236,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: true, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("exp-") { model_family!( @@ -209,6 +250,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::UnifiedExec, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) // Production models. @@ -223,6 +265,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") @@ -238,6 +281,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_parallel_tool_calls: true, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5.1") { model_family!( @@ -251,6 +295,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5") { model_family!( @@ -260,6 +305,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { shell_type: ConfigShellToolType::Default, support_verbosity: true, truncation_policy: TruncationPolicy::Bytes(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else { derive_default_model_family(slug) @@ -271,6 +317,8 @@ fn derive_default_model_family(model: &str) -> ModelFamily { slug: model.to_string(), family: model.to_string(), needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1302b2343d..a0b42ddbe4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -550,7 +550,7 @@ impl ChatWidget { fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window - .or(self.config.model_context_window) + .or(self.model_family.context_window) .map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) @@ -2024,6 +2024,7 @@ impl ChatWidget { self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), + &self.model_family, total_usage, context_usage, &self.conversation_id, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 126c91f9d8..0135abff73 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -98,7 +98,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { #[test] fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let conversation_id = ConversationId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -154,7 +154,7 @@ fn resumed_initial_messages_render_history() { /// Entering review mode uses the hint provided by the review request. #[test] fn entered_review_mode_uses_request_hint() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -175,7 +175,7 @@ fn entered_review_mode_uses_request_hint() { /// Entering review mode renders the current changes banner when requested. #[test] fn entered_review_mode_defaults_to_current_changes_banner() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -195,7 +195,7 @@ fn entered_review_mode_defaults_to_current_changes_banner() { /// the closing banner while clearing review mode state. #[test] fn exited_review_mode_emits_results_and_finishes() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let review = ReviewOutputEvent { findings: vec![ReviewFinding { @@ -229,7 +229,7 @@ fn exited_review_mode_emits_results_and_finishes() { /// Exiting review restores the pre-review context window indicator. #[test] fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. @@ -278,7 +278,7 @@ fn review_restores_context_window_indicator() { /// Receiving a TokenCount event without usage clears the context indicator. #[test] fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_compact_tokens = 12_700; @@ -304,7 +304,7 @@ fn token_count_none_resets_context_indicator() { #[test] fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); chat.config.model_context_window = None; let auto_compact_limit = 200_000; @@ -371,7 +371,9 @@ async fn helpers_are_available_and_do_not_panic() { } // --- Helpers for tests that need direct construction and event draining --- -fn make_chatwidget_manual() -> ( +fn make_chatwidget_manual( + model_override: Option<&str>, +) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, @@ -379,7 +381,10 @@ fn make_chatwidget_manual() -> ( let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); - let cfg = test_config(); + let mut cfg = test_config(); + if let Some(model) = model_override { + cfg.model = model.to_string(); + } let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -447,7 +452,7 @@ pub(crate) fn make_chatwidget_manual_with_sender() -> ( tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (widget, rx, op_rx) = make_chatwidget_manual(); + let (widget, rx, op_rx) = make_chatwidget_manual(None); let app_event_tx = widget.app_event_tx.clone(); (widget, app_event_tx, rx, op_rx) } @@ -543,7 +548,7 @@ fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: None, @@ -592,7 +597,7 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { #[test] fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: Some(RateLimitWindow { @@ -645,7 +650,7 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = NUDGE_MODEL_SLUG.to_string(); @@ -661,7 +666,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); @@ -686,7 +691,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -702,7 +707,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(None); chat.config.model = "gpt-5".to_string(); chat.auth_manager = AuthManager::from_auth_for_testing(auth); @@ -723,7 +728,7 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = "gpt-5".to_string(); @@ -739,7 +744,7 @@ fn rate_limit_switch_prompt_popup_snapshot() { #[test] fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { @@ -784,7 +789,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { #[test] fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { @@ -969,7 +974,7 @@ fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { #[test] fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(true); @@ -983,7 +988,7 @@ fn empty_enter_during_task_does_not_queue() { #[test] fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -1016,7 +1021,7 @@ fn alt_up_edits_most_recent_queued_message() { /// is queued repeatedly. #[test] fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); @@ -1042,7 +1047,7 @@ fn enqueueing_history_prompt_multiple_times_is_stable() { #[test] fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -1072,7 +1077,7 @@ fn streaming_final_answer_keeps_task_running_state() { #[test] fn ctrl_c_shutdown_ignores_caps_lock() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); @@ -1084,7 +1089,7 @@ fn ctrl_c_shutdown_ignores_caps_lock() { #[test] fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.insert_str("draft message "); chat.bottom_pane @@ -1118,7 +1123,7 @@ fn ctrl_c_cleared_prompt_is_recoverable_via_history() { #[test] fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-1", "echo done"); @@ -1148,7 +1153,7 @@ fn exec_history_cell_shows_working_then_completed() { #[test] fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-2", "false"); @@ -1172,7 +1177,7 @@ fn exec_history_cell_shows_working_then_failed() { #[test] fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin = begin_exec_with_source( &mut chat, @@ -1200,7 +1205,7 @@ fn exec_history_shows_unified_exec_startup_commands() { /// OpenReviewCustomPrompt to the app event channel. #[test] fn review_popup_custom_prompt_action_sends_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Open the preset selection popup chat.open_review_popup(); @@ -1225,7 +1230,7 @@ fn review_popup_custom_prompt_action_sends_event() { #[test] fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); @@ -1257,7 +1262,7 @@ fn slash_init_skips_when_project_doc_exists() { #[test] fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Quit); @@ -1266,7 +1271,7 @@ fn slash_quit_requests_exit() { #[test] fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Exit); @@ -1275,7 +1280,7 @@ fn slash_exit_requests_exit() { #[test] fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Resume); @@ -1284,7 +1289,7 @@ fn slash_resume_opens_picker() { #[test] fn slash_undo_sends_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Undo); @@ -1296,7 +1301,7 @@ fn slash_undo_sends_op() { #[test] fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); chat.current_rollout_path = Some(rollout_path.clone()); @@ -1313,7 +1318,7 @@ fn slash_rollout_displays_current_path() { #[test] fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Rollout); @@ -1332,7 +1337,7 @@ fn slash_rollout_handles_missing_path() { #[test] fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-1".to_string(), @@ -1369,7 +1374,7 @@ fn undo_success_events_render_info_messages() { #[test] fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-2".to_string(), @@ -1404,7 +1409,7 @@ fn undo_failure_events_render_error_message() { #[test] fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-hint".to_string(), @@ -1424,7 +1429,7 @@ fn undo_started_hides_interrupt_hint() { /// The commit picker shows only commit subjects (no timestamps). #[test] fn review_commit_picker_shows_subjects_without_timestamps() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1486,7 +1491,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() { /// and uses the same text for the user-facing hint. #[test] fn custom_prompt_submit_sends_review_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Paste prompt text via ChatWidget handler, then submit @@ -1514,7 +1519,7 @@ fn custom_prompt_submit_sends_review_op() { /// Hitting Enter on an empty custom prompt view does not submit. #[test] fn custom_prompt_enter_empty_does_not_send() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Enter without any text @@ -1526,7 +1531,7 @@ fn custom_prompt_enter_empty_does_not_send() { #[test] fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let image_path = chat.config.cwd.join("example.png"); chat.handle_codex_event(Event { @@ -1547,7 +1552,7 @@ fn view_image_tool_call_adds_history_cell() { // marker (replacing the spinner) and flushes it into history. #[test] fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin a long-running command so we have an active exec cell with a spinner. begin_exec(&mut chat, "call-int", "sleep 1"); @@ -1576,7 +1581,7 @@ fn interrupt_exec_marks_failed_snapshot() { // suggesting the user to tell the model what to do differently and to use /feedback. #[test] fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate an in-progress task so the widget is in a running state. chat.handle_codex_event(Event { @@ -1607,7 +1612,7 @@ fn interrupted_turn_error_message_snapshot() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[test] fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1642,7 +1647,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[tokio::test] async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1729,7 +1734,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.model = "gpt-5-codex".to_string(); chat.open_model_popup(); @@ -1740,7 +1745,7 @@ fn model_selection_popup_snapshot() { #[test] fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.notices.hide_full_access_warning = None; chat.open_approvals_popup(); @@ -1779,7 +1784,7 @@ fn preset_matching_ignores_extra_writable_roots() { #[test] fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1794,7 +1799,7 @@ fn full_access_confirmation_popup_snapshot() { #[cfg(target_os = "windows")] #[test] fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1812,7 +1817,7 @@ fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { #[cfg(target_os = "windows")] #[test] fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_windows_sandbox_enabled(false); chat.config.forced_auto_mode_downgraded_on_windows = true; @@ -1834,7 +1839,7 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1849,7 +1854,7 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1864,7 +1869,7 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); @@ -1885,7 +1890,7 @@ fn reasoning_popup_shows_extra_high_with_space() { #[test] fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, @@ -1925,7 +1930,7 @@ fn single_reasoning_option_skips_selection() { #[test] fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the feedback category selection popup via slash command. chat.dispatch_command(SlashCommand::Feedback); @@ -1936,7 +1941,7 @@ fn feedback_selection_popup_snapshot() { #[test] fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the consent popup directly for a chosen category. chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); @@ -1947,7 +1952,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.model = "gpt-5.1".to_string(); chat.open_model_popup(); @@ -1967,7 +1972,7 @@ fn reasoning_popup_escape_returns_to_model_popup() { #[test] fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // 1) Start "ls -la" (List) let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); @@ -1998,7 +2003,7 @@ fn exec_history_extends_previous_when_consecutive() { #[test] fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin_ls = begin_exec_with_source( &mut chat, @@ -2021,7 +2026,7 @@ fn user_shell_command_renders_output_not_exploring() { #[test] fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) @@ -2045,7 +2050,7 @@ fn disabled_slash_command_while_task_running_snapshot() { #[test] fn approval_modal_exec_snapshot() { // Build a chat widget with manual channels to avoid spawning the agent. - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). chat.config.approval_policy = AskForApproval::OnRequest; // Inject an exec approval request to display the approval modal. @@ -2100,7 +2105,7 @@ fn approval_modal_exec_snapshot() { // Ensures spacing looks correct when no reason text is provided. #[test] fn approval_modal_exec_without_reason_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; let ev = ExecApprovalRequestEvent { @@ -2139,7 +2144,7 @@ fn approval_modal_exec_without_reason_snapshot() { // Snapshot test: patch approval modal #[test] fn approval_modal_patch_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; // Build a small changeset and a reason/grant_root to exercise the prompt text. @@ -2178,7 +2183,7 @@ fn approval_modal_patch_snapshot() { #[test] fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // Simulate a running task to enable queuing of user inputs. chat.bottom_pane.set_task_running(true); @@ -2217,7 +2222,7 @@ fn interrupt_restores_queued_messages_into_composer() { #[test] fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); chat.bottom_pane @@ -2255,7 +2260,7 @@ fn interrupt_prepends_queued_messages_before_existing_composer_text() { fn ui_snapshots_small_heights_idle() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(); + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); for h in [1u16, 2, 3] { let name = format!("chat_small_idle_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); @@ -2272,7 +2277,7 @@ fn ui_snapshots_small_heights_idle() { fn ui_snapshots_small_heights_task_running() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate status line chat.handle_codex_event(Event { id: "task-1".into(), @@ -2303,7 +2308,7 @@ fn ui_snapshots_small_heights_task_running() { fn status_widget_and_approval_modal_snapshot() { use codex_core::protocol::ExecApprovalRequestEvent; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Begin a running task so the status indicator would be active. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2356,7 +2361,7 @@ fn status_widget_and_approval_modal_snapshot() { // Ensures the VT100 rendering of the status indicator is stable when active. #[test] fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate the status indicator by simulating a task start. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2383,7 +2388,7 @@ fn status_widget_active_snapshot() { #[test] fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "bg-1".into(), @@ -2399,7 +2404,7 @@ fn background_event_updates_status_header() { #[test] fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -2497,7 +2502,7 @@ fn apply_patch_events_emit_history_cells() { #[test] fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2546,7 +2551,7 @@ fn apply_patch_manual_approval_adjusts_header() { #[test] fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2599,7 +2604,7 @@ fn apply_patch_manual_flow_snapshot() { #[test] fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -2638,7 +2643,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { #[test] fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -2716,7 +2721,7 @@ fn apply_patch_full_flow_integration_like() { #[test] fn apply_patch_untrusted_shows_approval_modal() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure approval policy is untrusted (OnRequest) chat.config.approval_policy = AskForApproval::OnRequest; @@ -2761,7 +2766,7 @@ fn apply_patch_untrusted_shows_approval_modal() { #[test] fn apply_patch_request_shows_diff_summary() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy = AskForApproval::OnRequest; @@ -2827,7 +2832,7 @@ fn apply_patch_request_shows_diff_summary() { #[test] fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -2863,7 +2868,7 @@ fn plan_update_renders_history_cell() { #[test] fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; chat.handle_codex_event(Event { @@ -2888,7 +2893,7 @@ fn stream_error_updates_status_indicator() { #[test] fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::Warning(WarningEvent { @@ -2907,7 +2912,7 @@ fn warning_event_adds_warning_history_cell() { #[test] fn stream_recovery_restores_previous_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "task".into(), msg: EventMsg::TaskStarted(TaskStartedEvent { @@ -2940,7 +2945,7 @@ fn stream_recovery_restores_previous_status_header() { #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin turn chat.handle_codex_event(Event { @@ -2994,7 +2999,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -3021,7 +3026,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { #[test] fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -3085,7 +3090,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { // then the exec block, another blank line, the status line, a blank line, and the composer. #[test] fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), @@ -3177,7 +3182,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { // E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks #[test] fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate a final agent message via streaming deltas instead of a single message @@ -3268,7 +3273,7 @@ printf 'fenced within fenced\n' #[test] fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::TaskStarted(TaskStartedEvent { diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 797eded5fa..7049d13fff 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,6 +7,7 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -70,6 +71,7 @@ struct StatusHistoryCell { pub(crate) fn new_status_output( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, @@ -81,6 +83,7 @@ pub(crate) fn new_status_output( let card = StatusHistoryCell::new( config, auth_manager, + model_family, total_usage, context_usage, session_id, @@ -97,6 +100,7 @@ impl StatusHistoryCell { fn new( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, @@ -119,7 +123,7 @@ impl StatusHistoryCell { let agents_summary = compose_agents_summary(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); - let context_window = config.model_context_window.and_then(|window| { + let context_window = model_family.context_window.and_then(|window| { context_usage.map(|usage| StatusContextWindowData { percent_remaining: usage.percent_of_context_window_remaining(window), tokens_in_context: usage.tokens_in_context_window(), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 35989883f1..1b16453c42 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,6 +8,8 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -37,6 +39,10 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } +fn test_model_family(config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(config.model.as_str(), config) +} + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -124,9 +130,12 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); + let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -177,9 +186,11 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -218,9 +229,11 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -258,9 +271,11 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -298,9 +313,11 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -336,9 +353,11 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -374,9 +393,11 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -427,9 +448,11 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -469,9 +492,11 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -529,9 +554,11 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -577,9 +604,11 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -634,9 +663,11 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -695,9 +726,11 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, @@ -742,9 +775,11 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); + let model_family = test_model_family(&config); let composite = new_status_output( &config, &auth_manager, + &model_family, &total_usage, Some(&last_usage), &None, From 06704b1a0fff5bfaf500c8a3420bad3432754cc9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 8 Dec 2025 16:00:24 -0800 Subject: [PATCH 091/159] fix: pre-main hardening logic must tolerate non-UTF-8 env vars (#7749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We received a bug report that Codex CLI crashes when an env var contains a non-ASCII character, or more specifically, cannot be decoded as UTF-8: ```shell $ RUST_BACKTRACE=full RÖDBURK=1 codex thread '' panicked at library/std/src/env.rs:162:57: called `Result::unwrap()` on an `Err` value: "RÃ\xB6DBURK" stack backtrace: 0: 0x101905c18 - __mh_execute_header 1: 0x1012bd76c - __mh_execute_header 2: 0x1019050e4 - __mh_execute_header 3: 0x101905ad8 - __mh_execute_header 4: 0x101905874 - __mh_execute_header 5: 0x101904f38 - __mh_execute_header 6: 0x1019347bc - __mh_execute_header 7: 0x10193472c - __mh_execute_header 8: 0x101937884 - __mh_execute_header 9: 0x101b3bcd0 - __mh_execute_header 10: 0x101b3c0bc - __mh_execute_header 11: 0x101927a20 - __mh_execute_header 12: 0x1005c58d8 - __mh_execute_header thread '' panicked at library/core/src/panicking.rs:225:5: panic in a function that cannot unwind stack backtrace: 0: 0x101905c18 - __mh_execute_header 1: 0x1012bd76c - __mh_execute_header 2: 0x1019050e4 - __mh_execute_header 3: 0x101905ad8 - __mh_execute_header 4: 0x101905874 - __mh_execute_header 5: 0x101904f38 - __mh_execute_header 6: 0x101934794 - __mh_execute_header 7: 0x10193472c - __mh_execute_header 8: 0x101937884 - __mh_execute_header 9: 0x101b3c144 - __mh_execute_header 10: 0x101b3c1a0 - __mh_execute_header 11: 0x101b3c158 - __mh_execute_header 12: 0x1005c5ef8 - __mh_execute_header thread caused non-unwinding panic. aborting. ``` I discovered I could reproduce this on a release build, but not a dev build, so between that and the unhelpful stack trace, my mind went to the pre-`main()` logic we run in prod builds. Sure enough, we were operating on `std::env::vars()` instead of `std::env::vars_os()`, which is why the non-UTF-8 environment variable was causing an issue. This PR updates the logic to use `std::env::vars_os()` and adds a unit test. And to be extra sure, I also verified the fix works with a local release build: ``` $ cargo build --bin codex --release $ RÖDBURK=1 ./target/release/codex --version codex-cli 0.0.0 ``` --- codex-rs/Cargo.lock | 1 + codex-rs/process-hardening/Cargo.toml | 3 + codex-rs/process-hardening/src/lib.rs | 98 +++++++++++++++++++-------- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3e7f0fd8b0..cfb4669747 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1483,6 +1483,7 @@ name = "codex-process-hardening" version = "0.0.0" dependencies = [ "libc", + "pretty_assertions", ] [[package]] diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml index 2a867572df..7cc88ed608 100644 --- a/codex-rs/process-hardening/Cargo.toml +++ b/codex-rs/process-hardening/Cargo.toml @@ -13,3 +13,6 @@ workspace = true [dependencies] libc = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/process-hardening/src/lib.rs b/codex-rs/process-hardening/src/lib.rs index 772647671f..fb6145f176 100644 --- a/codex-rs/process-hardening/src/lib.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(unix)] +use std::ffi::OsString; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; + /// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform /// various process hardening steps, such as /// - disabling core dumps @@ -51,15 +57,7 @@ pub(crate) fn pre_main_hardening_linux() { // Official Codex releases are MUSL-linked, which means that variables such // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here. - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { @@ -73,15 +71,7 @@ pub(crate) fn pre_main_hardening_bsd() { // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars set_core_file_size_limit_to_zero(); - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { std::env::remove_var(key); @@ -106,15 +96,7 @@ pub(crate) fn pre_main_hardening_macos() { // Remove all DYLD_ environment variables, which can be used to subvert // library loading. - let dyld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("DYLD_") { - Some(key) - } else { - None - } - }) - .collect(); + let dyld_keys = env_keys_with_prefix(std::env::vars_os(), b"DYLD_"); for key in dyld_keys { unsafe { @@ -144,3 +126,65 @@ fn set_core_file_size_limit_to_zero() { pub(crate) fn pre_main_hardening_windows() { // TODO(mbolin): Perform the appropriate configuration for Windows. } + +#[cfg(unix)] +fn env_keys_with_prefix(vars: I, prefix: &[u8]) -> Vec +where + I: IntoIterator, +{ + vars.into_iter() + .filter_map(|(key, _)| { + key.as_os_str() + .as_bytes() + .starts_with(prefix) + .then_some(key) + }) + .collect() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn env_keys_with_prefix_handles_non_utf8_entries() { + // RÖDBURK + let non_utf8_key1 = OsStr::from_bytes(b"R\xD6DBURK").to_os_string(); + assert!(non_utf8_key1.clone().into_string().is_err()); + let non_utf8_key2 = OsString::from_vec(vec![b'L', b'D', b'_', 0xF0]); + assert!(non_utf8_key2.clone().into_string().is_err()); + + let non_utf8_value = OsString::from_vec(vec![0xF0, 0x9F, 0x92, 0xA9]); + + let keys = env_keys_with_prefix( + vec![ + (non_utf8_key1, non_utf8_value.clone()), + (non_utf8_key2.clone(), non_utf8_value), + ], + b"LD_", + ); + assert_eq!( + keys, + vec![non_utf8_key2], + "non-UTF-8 env entries with LD_ prefix should be retained" + ); + } + + #[test] + fn env_keys_with_prefix_filters_only_matching_keys() { + let ld_test_var = OsStr::from_bytes(b"LD_TEST"); + let vars = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin")), + (ld_test_var.to_os_string(), OsString::from("1")), + (OsString::from("DYLD_FOO"), OsString::from("bar")), + ]; + + let keys = env_keys_with_prefix(vars, b"LD_"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].as_os_str(), ld_test_var); + } +} From 0f2b589d5ef37f31146345ac4e92163afc5cbd01 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 8 Dec 2025 16:09:28 -0800 Subject: [PATCH 092/159] Revert "feat: windows codesign with Azure trusted signing" (#7753) Reverts openai/codex#7675 --- .github/actions/windows-code-sign/action.yml | 54 -------------------- .github/workflows/rust-release.yml | 12 ----- 2 files changed, 66 deletions(-) delete mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml deleted file mode 100644 index 17a4fbf999..0000000000 --- a/.github/actions/windows-code-sign/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: windows-code-sign -description: Sign Windows binaries with Azure Trusted Signing. -inputs: - target: - description: Target triple for the artifacts to sign. - required: true - client-id: - description: Azure Trusted Signing client ID. - required: true - tenant-id: - description: Azure tenant ID for Trusted Signing. - required: true - subscription-id: - description: Azure subscription ID for Trusted Signing. - required: true - endpoint: - description: Azure Trusted Signing endpoint. - required: true - account-name: - description: Azure Trusted Signing account name. - required: true - certificate-profile-name: - description: Certificate profile name for signing. - required: true - -runs: - using: composite - steps: - - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ inputs.client-id }} - tenant-id: ${{ inputs.tenant-id }} - subscription-id: ${{ inputs.subscription-id }} - - - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@v0 - with: - endpoint: ${{ inputs.endpoint }} - trusted-signing-account-name: ${{ inputs.account-name }} - certificate-profile-name: ${{ inputs.certificate-profile-name }} - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b90f0027fa..c3e9eeef9a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,18 +110,6 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ contains(matrix.target, 'windows') }} - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From cacfd003acbc206c0c8d2caacd0b2ccdc4cf0232 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 17:30:42 -0800 Subject: [PATCH 093/159] override instructions using `ModelInfo` (#7754) Making sure we can override base instructions --- .../codex-api/tests/models_integration.rs | 1 + .../core/src/openai_models/model_family.rs | 2 + .../core/src/openai_models/models_manager.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 101 ++++++++++++++++++ codex-rs/protocol/src/openai_models.rs | 2 + 5 files changed, 107 insertions(+) diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 6ef328188f..20eb64d5cd 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -77,6 +77,7 @@ async fn models_client_hits_models_endpoint() { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, }], etag: String::new(), }; diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6665165ee5..094fb01370 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -102,6 +102,7 @@ impl ModelFamily { if model.slug == self.slug { self.default_reasoning_effort = Some(model.default_reasoning_level); self.shell_type = model.shell_type; + self.base_instructions = model.base_instructions.unwrap_or(self.base_instructions); } } self @@ -357,6 +358,7 @@ mod tests { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, } } diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 9ebf0112ad..09eedeebaf 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -216,6 +216,7 @@ mod tests { "supported_in_api": true, "priority": priority, "upgrade": null, + "base_instructions": null, })) .expect("valid model") } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index b13188d5d1..0f80407473 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; @@ -67,6 +68,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supported_in_api: true, priority: 1, upgrade: None, + base_instructions: None, }; let models_mock = mount_models_once( @@ -167,6 +169,105 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_apply_remote_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let model = "test-gpt-5-remote"; + + let remote_base = "Use the remote base instructions only."; + let remote_model = ModelInfo { + slug: model.to_string(), + display_name: "Parallel Remote".to_string(), + description: Some("A remote model with custom instructions".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: Some(remote_base.to_string()), + }; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + etag: String::new(), + }, + ) + .await; + + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = "gpt-5.1".to_string(); + }); + + let TestCodex { + codex, + cwd, + conversation_manager, + .. + } = builder.build(&server).await?; + + let models_manager = conversation_manager.get_models_manager(); + wait_for_model_available(&models_manager, model).await; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.to_string()), + effort: None, + summary: None, + }) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello remote".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let body = response_mock.single_request().body_json(); + let instructions = body["instructions"].as_str().unwrap(); + assert_eq!(instructions, remote_base); + + Ok(()) +} + async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 942303a902..c5500f1cc5 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -135,6 +135,8 @@ pub struct ModelInfo { pub priority: i32, #[serde(default)] pub upgrade: Option, + #[serde(default)] + pub base_instructions: Option, } /// Response wrapper for `/models`. From 68505abf0f1041e3bafe39638f2a0269318565e9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Dec 2025 17:42:24 -0800 Subject: [PATCH 094/159] use chatgpt provider for /models (#7756) This endpoint only exist on chatgpt --- codex-rs/core/src/openai_models/models_manager.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 09eedeebaf..03dbd39d3d 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,6 +1,7 @@ use chrono::Utc; use codex_api::ModelsClient; use codex_api::ReqwestTransport; +use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -61,7 +62,7 @@ impl ModelsManager { } let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; + let api_provider = provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); From 933e247e9f68f11ec4d89a39654b381c778b8f2e Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Tue, 9 Dec 2025 10:45:20 +0800 Subject: [PATCH 095/159] Fix transcript pager page continuity (#7363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fix PageUp/PageDown behaviour in the Ctrl+T transcript overlay so that paging is continuous and reversible, and add tests to lock in the expected behaviour. ## Why Today, paging in the transcript overlay uses the raw viewport height instead of the effective content height after layout. Because the overlay reserves some rows for chrome (header/footer), this can cause: - PageDown to skip transcript lines between pages. - PageUp/PageDown not to “round-trip” cleanly (PageDown then PageUp does not always return to the same set of visible lines). This shows up when inspecting longer transcripts via Ctrl+T; see #7356 for context. ## How - Add a dedicated `PagerView::page_step` helper that computes the page size from the last rendered content height and falls back to `content_area(viewport_area).height` when that is not yet available. - Use `page_step(...)` for both PageUp and PageDown (including SPACE) so the scroll step always matches the actual content area height, not the full viewport height. - Add a focused test `transcript_overlay_paging_is_continuous_and_round_trips` that: - Renders a synthetic transcript with numbered `line-NN` rows. - Asserts that successive PageDown operations show continuous line numbers (no gaps). - Asserts that PageDown+PageUp and PageUp+PageDown round-trip correctly from non-edge offsets. The change is limited to `codex-rs/tui/src/pager_overlay.rs` and only affects the transcript overlay paging semantics. ## Related issue - #7356 ## Testing On Windows 11, using PowerShell 7 in the repo root: ```powershell cargo test cargo clippy --tests cargo fmt -- --config imports_granularity=Item ``` - All tests passed. - `cargo clippy --tests` reported some pre-existing warnings that are unrelated to this change; no new lints were introduced in the modified code. --------- Signed-off-by: muyuanjin <24222808+muyuanjin@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/tui/src/pager_overlay.rs | 112 ++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70e..f5854d5545 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -241,12 +241,12 @@ impl PagerView { self.scroll_offset = self.scroll_offset.saturating_add(1); } e if KEY_PAGE_UP.is_press(e) => { - let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { - let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(page_height); } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; @@ -263,6 +263,16 @@ impl PagerView { Ok(()) } + /// Returns the height of one page in content rows. + /// + /// Prefers the last rendered content height (excluding header/footer chrome); + /// if no render has occurred yet, falls back to the content area height + /// computed from the given viewport. + fn page_height(&self, viewport_area: Rect) -> usize { + self.last_content_height + .unwrap_or_else(|| self.content_area(viewport_area).height as usize) + } + fn update_last_content_height(&mut self, height: u16) { self.last_content_height = Some(height as usize); } @@ -812,6 +822,100 @@ mod tests { assert_snapshot!(term.backend()); } + /// Render transcript overlay and return visible line numbers (`line-NN`) in order. + fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let top_h = area.height.saturating_sub(3); + let top = Rect::new(area.x, area.y, area.width, top_h); + let content_area = overlay.view.content_area(top); + + let mut nums = Vec::new(); + for y in content_area.y..content_area.bottom() { + let mut line = String::new(); + for x in content_area.x..content_area.right() { + line.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if let Some(n) = line + .split_whitespace() + .find_map(|w| w.strip_prefix("line-")) + .and_then(|s| s.parse().ok()) + { + nums.push(n); + } + } + nums + } + + #[test] + fn transcript_overlay_paging_is_continuous_and_round_trips() { + let mut overlay = TranscriptOverlay::new( + (0..50) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line-{i:02}"))], + }) as Arc + }) + .collect(), + ); + let area = Rect::new(0, 0, 40, 15); + + // Prime layout so last_content_height is populated and paging uses the real content height. + let mut buf = Buffer::empty(area); + overlay.view.scroll_offset = 0; + overlay.render(area, &mut buf); + let page_height = overlay.view.page_height(area); + + // Scenario 1: starting from the top, PageDown should show the next page of content. + overlay.view.scroll_offset = 0; + let page1 = transcript_line_numbers(&mut overlay, area); + let page1_len = page1.len(); + let expected_page1: Vec = (0..page1_len).collect(); + assert_eq!( + page1, expected_page1, + "first page should start at line-00 and show a full page of content" + ); + + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let page2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + page2.len(), + page1_len, + "second page should have the same number of visible lines as the first page" + ); + let expected_page2_first = *page1.last().unwrap() + 1; + assert_eq!( + page2[0], expected_page2_first, + "second page after PageDown should immediately follow the first page" + ); + + // Scenario 2: from an interior offset (start=3), PageDown then PageUp should round-trip. + let interior_offset = 3usize; + overlay.view.scroll_offset = interior_offset; + let before = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let after = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before, after, + "PageDown+PageUp from interior offset ({interior_offset}) should round-trip" + ); + + // Scenario 3: from the top of the second page, PageUp then PageDown should round-trip. + overlay.view.scroll_offset = page_height; + let before2 = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let after2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before2, after2, + "PageUp+PageDown from the top of the second page should round-trip" + ); + } + #[test] fn static_overlay_wraps_long_lines() { let mut overlay = StaticOverlay::with_title( From 80140c6d9d272aeccc115beef4197e56a1e565ee Mon Sep 17 00:00:00 2001 From: cassirer-openai Date: Tue, 9 Dec 2025 14:56:23 +0700 Subject: [PATCH 096/159] Use codex-max prompt/tools for experimental models. (#7765) --- codex-rs/core/src/openai_models/model_family.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 094fb01370..33a130cb3d 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -220,22 +220,18 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Tokens(10_000), ) - // Internal models. - } else if slug.starts_with("codex-exp-") { + // Experimental models. + } else if slug.starts_with("exp-codex") { + // Same as gpt-5.1-codex-max. model_family!( slug, slug, supports_reasoning_summaries: true, reasoning_summary_format: ReasoningSummaryFormat::Experimental, - base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - experimental_supported_tools: vec![ - "grep_files".to_string(), - "list_dir".to_string(), - "read_file".to_string(), - ], shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, - support_verbosity: true, + support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), ) From 6382dc2338aa048de7a4ecf55dc234080231ee1d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 9 Dec 2025 17:00:56 +0000 Subject: [PATCH 097/159] chore: enable parallel tc (#7589) --- codex-rs/core/src/codex.rs | 14 ++------------ codex-rs/core/src/features.rs | 6 ++++++ codex-rs/core/src/openai_models/model_family.rs | 4 ++-- codex-rs/core/templates/parallel/instructions.md | 13 ------------- 4 files changed, 10 insertions(+), 27 deletions(-) delete mode 100644 codex-rs/core/templates/parallel/instructions.md diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 74ef11c323..8060929149 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2174,21 +2174,11 @@ async fn run_turn( .get_model_family() .supports_parallel_tool_calls; - // TODO(jif) revert once testing phase is done. - let parallel_tool_calls = model_supports_parallel && sess.enabled(Feature::ParallelToolCalls); - let mut base_instructions = turn_context.base_instructions.clone(); - if parallel_tool_calls { - static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - let family = turn_context.client.get_model_family(); - let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); - new_instructions.push_str(INSTRUCTIONS); - base_instructions = Some(new_instructions); - } let prompt = Prompt { input, tools: router.specs(), - parallel_tool_calls, - base_instructions_override: base_instructions, + parallel_tool_calls: model_supports_parallel && sess.enabled(Feature::ParallelToolCalls), + base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), }; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 69442815e7..43a89480f4 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -268,6 +268,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ParallelToolCalls, + key: "parallel", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ViewImageTool, key: "view_image_tool", diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 33a130cb3d..8a3853d60b 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -259,7 +259,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), @@ -275,7 +275,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), diff --git a/codex-rs/core/templates/parallel/instructions.md b/codex-rs/core/templates/parallel/instructions.md deleted file mode 100644 index 292d585e45..0000000000 --- a/codex-rs/core/templates/parallel/instructions.md +++ /dev/null @@ -1,13 +0,0 @@ - -## Exploration and reading files - -- **Think first.** Before any tool call, decide ALL files/resources you will need. -- **Batch everything.** If you need multiple files (even from different places), read them together. -- **multi_tool_use.parallel** Use `multi_tool_use.parallel` to parallelize tool calls and only this. -- **Only make sequential calls if you truly cannot know the next file without seeing a result first.** -- **Workflow:** (a) plan all needed reads → (b) issue one parallel batch → (c) analyze results → (d) repeat if new, unpredictable reads arise. - -**Additional notes**: -* Always maximize parallelism. Never read files one-by-one unless logically unavoidable. -* This concern every read/list/search operations including, but not only, `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`, ... -* Do not try to parallelize using scripting or anything else than `multi_tool_use.parallel`. From 2237b701b6eb5455140bd50cd522c5625691fc3f Mon Sep 17 00:00:00 2001 From: Tyler Anton Date: Tue, 9 Dec 2025 09:04:36 -0800 Subject: [PATCH 098/159] Fix Nix cargo output hashes for rmcp and filedescriptor (#7762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7759: - Drop the stale `rmcp` entry from `codex-rs/default.nix`’s `cargoLock.outputHashes` since the crate now comes from crates.io and no longer needs a git hash. - Add the missing hash for the filedescriptor-0.8.3 git dependency (from `pakrym/wezterm`) so `buildRustPackage` can vendor it. --- codex-rs/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index 867e57ee2d..819ca7aad2 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -22,7 +22,7 @@ rustPlatform.buildRustPackage (_: { cargoLock.outputHashes = { "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; "crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728="; - "rmcp-0.9.0" = "sha256-0iPrpf0Ha/facO3p5e0hUKHBqGp/iS+C+OdS+pRKMOU="; + "filedescriptor-0.8.3" = "sha256-aIbzfHYjPDzWSZrgbauezGzg6lm3frhyBbU01gTQpaE="; }; meta = with lib; { From 164265bed1eb0d0ebf453196c39c79809d4f1b7a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 9 Dec 2025 09:23:51 -0800 Subject: [PATCH 099/159] Vendor ConPtySystem (#7656) The repo we were depending on is very large and we need very small part of it. --------- Co-authored-by: Pavel --- .codespellignore | 1 + codex-rs/Cargo.lock | 13 +- codex-rs/Cargo.toml | 1 - codex-rs/utils/pty/Cargo.toml | 16 + codex-rs/utils/pty/src/lib.rs | 16 +- codex-rs/utils/pty/src/win/conpty.rs | 144 +++++++++ codex-rs/utils/pty/src/win/mod.rs | 169 ++++++++++ codex-rs/utils/pty/src/win/procthreadattr.rs | 91 ++++++ codex-rs/utils/pty/src/win/psuedocon.rs | 322 +++++++++++++++++++ third_party/wezterm/LICENSE | 21 ++ 10 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 codex-rs/utils/pty/src/win/conpty.rs create mode 100644 codex-rs/utils/pty/src/win/mod.rs create mode 100644 codex-rs/utils/pty/src/win/procthreadattr.rs create mode 100644 codex-rs/utils/pty/src/win/psuedocon.rs create mode 100644 third_party/wezterm/LICENSE diff --git a/.codespellignore b/.codespellignore index 546a192701..d74f5ed86c 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1 +1,2 @@ iTerm +psuedo \ No newline at end of file diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cfb4669747..2dde6c07cd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1673,8 +1673,13 @@ name = "codex-utils-pty" version = "0.0.0" dependencies = [ "anyhow", + "filedescriptor", + "lazy_static", + "log", "portable-pty", + "shared_library", "tokio", + "winapi", ] [[package]] @@ -2578,7 +2583,8 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.3" -source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", "thiserror 1.0.69", @@ -4656,7 +4662,8 @@ dependencies = [ [[package]] name = "portable-pty" version = "0.9.0" -source = "git+https://github.com/pakrym/wezterm?branch=PSUEDOCONSOLE_INHERIT_CURSOR#fe38df8409545a696909aa9a09e63438630f217d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ "anyhow", "bitflags 1.3.2", @@ -4665,7 +4672,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.29.0", + "nix 0.28.0", "serial2", "shared_library", "shell-words", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2086fbe897..9f55f67ce3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -289,7 +289,6 @@ opt-level = 0 # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } -portable-pty = { git = "https://github.com/pakrym/wezterm", branch = "PSUEDOCONSOLE_INHERIT_CURSOR" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } # Uncomment to debug local changes. diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index 2b3de5aa15..1a460ea3de 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -11,3 +11,19 @@ workspace = true anyhow = { workspace = true } portable-pty = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } + +[target.'cfg(windows)'.dependencies] +filedescriptor = "0.8.3" +lazy_static = { workspace = true } +log = { workspace = true } +shared_library = "0.1.9" +winapi = { version = "0.3.9", features = [ + "handleapi", + "minwinbase", + "processthreadsapi", + "synchapi", + "winbase", + "wincon", + "winerror", + "winnt", +] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 23d69b6f6a..dbaf4b81f7 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -7,7 +7,11 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::time::Duration; +#[cfg(windows)] +mod win; + use anyhow::Result; +#[cfg(not(windows))] use portable_pty::native_pty_system; use portable_pty::CommandBuilder; use portable_pty::MasterPty; @@ -125,6 +129,16 @@ pub struct SpawnedPty { pub exit_rx: oneshot::Receiver, } +#[cfg(windows)] +fn platform_native_pty_system() -> Box { + Box::new(win::ConPtySystem::default()) +} + +#[cfg(not(windows))] +fn platform_native_pty_system() -> Box { + native_pty_system() +} + pub async fn spawn_pty_process( program: &str, args: &[String], @@ -136,7 +150,7 @@ pub async fn spawn_pty_process( anyhow::bail!("missing program for PTY spawn"); } - let pty_system = native_pty_system(); + let pty_system = platform_native_pty_system(); let pair = pty_system.openpty(PtySize { rows: 24, cols: 80, diff --git a/codex-rs/utils/pty/src/win/conpty.rs b/codex-rs/utils/pty/src/win/conpty.rs new file mode 100644 index 0000000000..03caaa36ad --- /dev/null +++ b/codex-rs/utils/pty/src/win/conpty.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use crate::win::psuedocon::PsuedoCon; +use anyhow::Error; +use filedescriptor::FileDescriptor; +use filedescriptor::Pipe; +use portable_pty::cmdbuilder::CommandBuilder; +use portable_pty::Child; +use portable_pty::MasterPty; +use portable_pty::PtyPair; +use portable_pty::PtySize; +use portable_pty::PtySystem; +use portable_pty::SlavePty; +use std::sync::Arc; +use std::sync::Mutex; +use winapi::um::wincon::COORD; + +#[derive(Default)] +pub struct ConPtySystem {} + +impl PtySystem for ConPtySystem { + fn openpty(&self, size: PtySize) -> anyhow::Result { + let stdin = Pipe::new()?; + let stdout = Pipe::new()?; + + let con = PsuedoCon::new( + COORD { + X: size.cols as i16, + Y: size.rows as i16, + }, + stdin.read, + stdout.write, + )?; + + let master = ConPtyMasterPty { + inner: Arc::new(Mutex::new(Inner { + con, + readable: stdout.read, + writable: Some(stdin.write), + size, + })), + }; + + let slave = ConPtySlavePty { + inner: master.inner.clone(), + }; + + Ok(PtyPair { + master: Box::new(master), + slave: Box::new(slave), + }) + } +} + +struct Inner { + con: PsuedoCon, + readable: FileDescriptor, + writable: Option, + size: PtySize, +} + +impl Inner { + pub fn resize( + &mut self, + num_rows: u16, + num_cols: u16, + pixel_width: u16, + pixel_height: u16, + ) -> Result<(), Error> { + self.con.resize(COORD { + X: num_cols as i16, + Y: num_rows as i16, + })?; + self.size = PtySize { + rows: num_rows, + cols: num_cols, + pixel_width, + pixel_height, + }; + Ok(()) + } +} + +#[derive(Clone)] +pub struct ConPtyMasterPty { + inner: Arc>, +} + +pub struct ConPtySlavePty { + inner: Arc>, +} + +impl MasterPty for ConPtyMasterPty { + fn resize(&self, size: PtySize) -> anyhow::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner.resize(size.rows, size.cols, size.pixel_width, size.pixel_height) + } + + fn get_size(&self) -> Result { + let inner = self.inner.lock().unwrap(); + Ok(inner.size) + } + + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?)) + } + + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new( + self.inner + .lock() + .unwrap() + .writable + .take() + .ok_or_else(|| anyhow::anyhow!("writer already taken"))?, + )) + } +} + +impl SlavePty for ConPtySlavePty { + fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result> { + let inner = self.inner.lock().unwrap(); + let child = inner.con.spawn_command(cmd)?; + Ok(Box::new(child)) + } +} diff --git a/codex-rs/utils/pty/src/win/mod.rs b/codex-rs/utils/pty/src/win/mod.rs new file mode 100644 index 0000000000..8206c9b890 --- /dev/null +++ b/codex-rs/utils/pty/src/win/mod.rs @@ -0,0 +1,169 @@ +#![allow(clippy::unwrap_used)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use anyhow::Context as _; +use filedescriptor::OwnedHandle; +use portable_pty::Child; +use portable_pty::ChildKiller; +use portable_pty::ExitStatus; +use std::io::Error as IoError; +use std::io::Result as IoResult; +use std::os::windows::io::AsRawHandle; +use std::pin::Pin; +use std::sync::Mutex; +use std::task::Context; +use std::task::Poll; +use winapi::shared::minwindef::DWORD; +use winapi::um::minwinbase::STILL_ACTIVE; +use winapi::um::processthreadsapi::*; +use winapi::um::synchapi::WaitForSingleObject; +use winapi::um::winbase::INFINITE; + +pub mod conpty; +mod procthreadattr; +mod psuedocon; + +pub use conpty::ConPtySystem; + +#[derive(Debug)] +pub struct WinChild { + proc: Mutex, +} + +impl WinChild { + fn is_complete(&mut self) -> IoResult> { + let mut status: DWORD = 0; + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + if status == STILL_ACTIVE { + Ok(None) + } else { + Ok(Some(ExitStatus::with_exit_code(status))) + } + } else { + Ok(None) + } + } + + fn do_kill(&mut self) -> IoResult<()> { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) }; + let err = IoError::last_os_error(); + if res != 0 { + Err(err) + } else { + Ok(()) + } + } +} + +impl ChildKiller for WinChild { + fn kill(&mut self) -> IoResult<()> { + self.do_kill().ok(); + Ok(()) + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +#[derive(Debug)] +pub struct WinChildKiller { + proc: OwnedHandle, +} + +impl ChildKiller for WinChildKiller { + fn kill(&mut self) -> IoResult<()> { + let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) }; + let err = IoError::last_os_error(); + if res != 0 { + Err(err) + } else { + Ok(()) + } + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +impl Child for WinChild { + fn try_wait(&mut self) -> IoResult> { + self.is_complete() + } + + fn wait(&mut self) -> IoResult { + if let Ok(Some(status)) = self.try_wait() { + return Ok(status); + } + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + unsafe { + WaitForSingleObject(proc.as_raw_handle() as _, INFINITE); + } + let mut status: DWORD = 0; + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + Ok(ExitStatus::with_exit_code(status)) + } else { + Err(IoError::last_os_error()) + } + } + + fn process_id(&self) -> Option { + let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) }; + if res == 0 { + None + } else { + Some(res) + } + } + + fn as_raw_handle(&self) -> Option { + let proc = self.proc.lock().unwrap(); + Some(proc.as_raw_handle()) + } +} + +impl std::future::Future for WinChild { + type Output = anyhow::Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { + match self.is_complete() { + Ok(Some(status)) => Poll::Ready(Ok(status)), + Err(err) => Poll::Ready(Err(err).context("Failed to retrieve process exit status")), + Ok(None) => { + let proc = self.proc.lock().unwrap().try_clone()?; + let waker = cx.waker().clone(); + std::thread::spawn(move || { + unsafe { + WaitForSingleObject(proc.as_raw_handle() as _, INFINITE); + } + waker.wake(); + }); + Poll::Pending + } + } + } +} diff --git a/codex-rs/utils/pty/src/win/procthreadattr.rs b/codex-rs/utils/pty/src/win/procthreadattr.rs new file mode 100644 index 0000000000..6d464e99de --- /dev/null +++ b/codex-rs/utils/pty/src/win/procthreadattr.rs @@ -0,0 +1,91 @@ +#![allow(clippy::uninit_vec)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use super::psuedocon::HPCON; +use anyhow::ensure; +use anyhow::Error; +use std::io::Error as IoError; +use std::mem; +use std::ptr; +use winapi::shared::minwindef::DWORD; +use winapi::um::processthreadsapi::*; + +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; + +pub struct ProcThreadAttributeList { + data: Vec, +} + +impl ProcThreadAttributeList { + pub fn with_capacity(num_attributes: DWORD) -> Result { + let mut bytes_required: usize = 0; + unsafe { + InitializeProcThreadAttributeList( + ptr::null_mut(), + num_attributes, + 0, + &mut bytes_required, + ) + }; + let mut data = Vec::with_capacity(bytes_required); + unsafe { data.set_len(bytes_required) }; + + let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _; + let res = unsafe { + InitializeProcThreadAttributeList(attr_ptr, num_attributes, 0, &mut bytes_required) + }; + ensure!( + res != 0, + "InitializeProcThreadAttributeList failed: {}", + IoError::last_os_error() + ); + Ok(Self { data }) + } + + pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.data.as_mut_slice().as_mut_ptr() as *mut _ + } + + pub fn set_pty(&mut self, con: HPCON) -> Result<(), Error> { + let res = unsafe { + UpdateProcThreadAttribute( + self.as_mut_ptr(), + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + con, + mem::size_of::(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + ensure!( + res != 0, + "UpdateProcThreadAttribute failed: {}", + IoError::last_os_error() + ); + Ok(()) + } +} + +impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) }; + } +} diff --git a/codex-rs/utils/pty/src/win/psuedocon.rs b/codex-rs/utils/pty/src/win/psuedocon.rs new file mode 100644 index 0000000000..a8db98eefe --- /dev/null +++ b/codex-rs/utils/pty/src/win/psuedocon.rs @@ -0,0 +1,322 @@ +#![allow(clippy::expect_used)] +#![allow(clippy::upper_case_acronyms)] + +// This file is copied from https://github.com/wezterm/wezterm (MIT license). +// Copyright (c) 2018-Present Wez Furlong +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use super::WinChild; +use crate::win::procthreadattr::ProcThreadAttributeList; +use anyhow::bail; +use anyhow::ensure; +use anyhow::Error; +use filedescriptor::FileDescriptor; +use filedescriptor::OwnedHandle; +use lazy_static::lazy_static; +use portable_pty::cmdbuilder::CommandBuilder; +use shared_library::shared_library; +use std::env; +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io::Error as IoError; +use std::mem; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::OsStringExt; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::path::Path; +use std::ptr; +use std::sync::Mutex; +use winapi::shared::minwindef::DWORD; +use winapi::shared::winerror::HRESULT; +use winapi::shared::winerror::S_OK; +use winapi::um::handleapi::*; +use winapi::um::processthreadsapi::*; +use winapi::um::winbase::CREATE_UNICODE_ENVIRONMENT; +use winapi::um::winbase::EXTENDED_STARTUPINFO_PRESENT; +use winapi::um::winbase::STARTF_USESTDHANDLES; +use winapi::um::winbase::STARTUPINFOEXW; +use winapi::um::wincon::COORD; +use winapi::um::winnt::HANDLE; + +pub type HPCON = HANDLE; + +pub const PSEUDOCONSOLE_RESIZE_QUIRK: DWORD = 0x2; +#[allow(dead_code)] +pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8; + +shared_library!(ConPtyFuncs, + pub fn CreatePseudoConsole( + size: COORD, + hInput: HANDLE, + hOutput: HANDLE, + flags: DWORD, + hpc: *mut HPCON + ) -> HRESULT, + pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT, + pub fn ClosePseudoConsole(hpc: HPCON), +); + +fn load_conpty() -> ConPtyFuncs { + let kernel = ConPtyFuncs::open(Path::new("kernel32.dll")).expect( + "this system does not support conpty. Windows 10 October 2018 or newer is required", + ); + + if let Ok(sideloaded) = ConPtyFuncs::open(Path::new("conpty.dll")) { + sideloaded + } else { + kernel + } +} + +lazy_static! { + static ref CONPTY: ConPtyFuncs = load_conpty(); +} + +pub struct PsuedoCon { + con: HPCON, +} + +unsafe impl Send for PsuedoCon {} +unsafe impl Sync for PsuedoCon {} + +impl Drop for PsuedoCon { + fn drop(&mut self) { + unsafe { (CONPTY.ClosePseudoConsole)(self.con) }; + } +} + +impl PsuedoCon { + pub fn new(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result { + let mut con: HPCON = INVALID_HANDLE_VALUE; + let result = unsafe { + (CONPTY.CreatePseudoConsole)( + size, + input.as_raw_handle() as _, + output.as_raw_handle() as _, + PSEUDOCONSOLE_RESIZE_QUIRK, + &mut con, + ) + }; + ensure!( + result == S_OK, + "failed to create psuedo console: HRESULT {result}" + ); + Ok(Self { con }) + } + + pub fn resize(&self, size: COORD) -> Result<(), Error> { + let result = unsafe { (CONPTY.ResizePseudoConsole)(self.con, size) }; + ensure!( + result == S_OK, + "failed to resize console to {}x{}: HRESULT: {}", + size.X, + size.Y, + result + ); + Ok(()) + } + + pub fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result { + let mut si: STARTUPINFOEXW = unsafe { mem::zeroed() }; + si.StartupInfo.cb = mem::size_of::() as u32; + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + + let mut attrs = ProcThreadAttributeList::with_capacity(1)?; + attrs.set_pty(self.con)?; + si.lpAttributeList = attrs.as_mut_ptr(); + + let mut pi: PROCESS_INFORMATION = unsafe { mem::zeroed() }; + + let (mut exe, mut cmdline) = build_cmdline(&cmd)?; + let cmd_os = OsString::from_wide(&cmdline); + + let cwd = resolve_current_directory(&cmd); + let mut env_block = build_environment_block(&cmd); + + let res = unsafe { + CreateProcessW( + exe.as_mut_ptr(), + cmdline.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + env_block.as_mut_ptr() as *mut _, + cwd.as_ref().map_or(ptr::null(), std::vec::Vec::as_ptr), + &mut si.StartupInfo, + &mut pi, + ) + }; + if res == 0 { + let err = IoError::last_os_error(); + let msg = format!( + "CreateProcessW `{:?}` in cwd `{:?}` failed: {}", + cmd_os, + cwd.as_ref().map(|c| OsString::from_wide(c)), + err + ); + log::error!("{msg}"); + bail!("{msg}"); + } + + let _main_thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread as _) }; + let proc = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as _) }; + + Ok(WinChild { + proc: Mutex::new(proc), + }) + } +} + +fn resolve_current_directory(cmd: &CommandBuilder) -> Option> { + let home = cmd + .get_env("USERPROFILE") + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let cwd = cmd + .get_cwd() + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let dir = cwd.or(home)?; + + let mut wide = Vec::new(); + if Path::new(&dir).is_relative() { + if let Ok(current_dir) = env::current_dir() { + wide.extend(current_dir.join(&dir).as_os_str().encode_wide()); + } else { + wide.extend(dir.encode_wide()); + } + } else { + wide.extend(dir.encode_wide()); + } + wide.push(0); + Some(wide) +} + +fn build_environment_block(cmd: &CommandBuilder) -> Vec { + let mut block = Vec::new(); + for (key, value) in cmd.iter_full_env_as_str() { + block.extend(OsStr::new(key).encode_wide()); + block.push(b'=' as u16); + block.extend(OsStr::new(value).encode_wide()); + block.push(0); + } + block.push(0); + block +} + +fn build_cmdline(cmd: &CommandBuilder) -> anyhow::Result<(Vec, Vec)> { + let exe_os: OsString = if cmd.is_default_prog() { + cmd.get_env("ComSpec") + .unwrap_or(OsStr::new("cmd.exe")) + .to_os_string() + } else { + let argv = cmd.get_argv(); + let Some(first) = argv.first() else { + anyhow::bail!("missing program name"); + }; + search_path(cmd, first) + }; + + let mut cmdline = Vec::new(); + append_quoted(&exe_os, &mut cmdline); + for arg in cmd.get_argv().iter().skip(1) { + cmdline.push(' ' as u16); + ensure!( + !arg.encode_wide().any(|c| c == 0), + "invalid encoding for command line argument {arg:?}" + ); + append_quoted(arg, &mut cmdline); + } + cmdline.push(0); + + let mut exe: Vec = exe_os.encode_wide().collect(); + exe.push(0); + + Ok((exe, cmdline)) +} + +fn search_path(cmd: &CommandBuilder, exe: &OsStr) -> OsString { + if let Some(path) = cmd.get_env("PATH") { + let extensions = cmd.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE")); + for path in env::split_paths(path) { + let candidate = path.join(exe); + if candidate.exists() { + return candidate.into_os_string(); + } + + for ext in env::split_paths(extensions) { + let ext = ext.to_str().unwrap_or(""); + let path = path + .join(exe) + .with_extension(ext.strip_prefix('.').unwrap_or(ext)); + if path.exists() { + return path.into_os_string(); + } + } + } + } + + exe.to_os_string() +} + +fn append_quoted(arg: &OsStr, cmdline: &mut Vec) { + if !arg.is_empty() + && !arg.encode_wide().any(|c| { + c == ' ' as u16 + || c == '\t' as u16 + || c == '\n' as u16 + || c == '\x0b' as u16 + || c == '\"' as u16 + }) + { + cmdline.extend(arg.encode_wide()); + return; + } + cmdline.push('"' as u16); + + let arg: Vec<_> = arg.encode_wide().collect(); + let mut i = 0; + while i < arg.len() { + let mut num_backslashes = 0; + while i < arg.len() && arg[i] == '\\' as u16 { + i += 1; + num_backslashes += 1; + } + + if i == arg.len() { + for _ in 0..num_backslashes * 2 { + cmdline.push('\\' as u16); + } + break; + } else if arg[i] == b'"' as u16 { + for _ in 0..num_backslashes * 2 + 1 { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } else { + for _ in 0..num_backslashes { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } + i += 1; + } + cmdline.push('"' as u16); +} diff --git a/third_party/wezterm/LICENSE b/third_party/wezterm/LICENSE new file mode 100644 index 0000000000..d6c7256999 --- /dev/null +++ b/third_party/wezterm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-Present Wez Furlong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From a7e3e37da8c20bf4d0910c90457242864d8eb790 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 9 Dec 2025 09:24:01 -0800 Subject: [PATCH 100/159] fix: allow sendmsg(2) and recvmsg(2) syscalls in our Linux sandbox (#7779) This changes our default Landlock policy to allow `sendmsg(2)` and `recvmsg(2)` syscalls. We believe these were originally denied out of an abundance of caution, but given that `send(2)` nor `recv(2)` are allowed today [which provide comparable capability to the `*msg` equivalents], we do not believe allowing them grants any privileges beyond what we already allow. Rather than using the syscall as the security boundary, preventing access to the potentially hazardous file descriptor in the first place seems like the right layer of defense. In particular, this makes it possible for `shell-tool-mcp` to run on Linux when using a read-only sandbox for the Bash process, as demonstrated by `accept_elicitation_for_prompt_rule()` now succeeding in CI. --- codex-rs/exec-server/tests/suite/mod.rs | 6 +----- codex-rs/linux-sandbox/src/landlock.rs | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/codex-rs/exec-server/tests/suite/mod.rs b/codex-rs/exec-server/tests/suite/mod.rs index 3a94f58579..397a4a6f2b 100644 --- a/codex-rs/exec-server/tests/suite/mod.rs +++ b/codex-rs/exec-server/tests/suite/mod.rs @@ -1,8 +1,4 @@ -// TODO(mbolin): Get this test working on Linux. Currently, it fails with: -// -// > Error: Mcp error: -32603: sandbox error: sandbox denied exec error, -// > exit code: 1, stdout: , stderr: Error: failed to send handshake datagram -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] mod accept_elicitation; #[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] mod list_tools; diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 5bc96130dd..119d859b26 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -102,12 +102,10 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), deny_syscall(libc::SYS_getsockname); deny_syscall(libc::SYS_shutdown); deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmsg); deny_syscall(libc::SYS_sendmmsg); // NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run // with their socketpair + child processes for sub-proc management // deny_syscall(libc::SYS_recvfrom); - deny_syscall(libc::SYS_recvmsg); deny_syscall(libc::SYS_recvmmsg); deny_syscall(libc::SYS_getsockopt); deny_syscall(libc::SYS_setsockopt); From 9df70a07729fac9d893a1399b37889412e595b93 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 9 Dec 2025 10:23:11 -0800 Subject: [PATCH 101/159] Add vim navigation keys to transcript pager (#7550) ## Summary - add vim-style pager navigation for transcript overlays (j/k, ctrl+f/b/d/u) without removing existing keys - add shift-space to page up ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69309d26da508329908b2dc8ca40afb7) --- codex-rs/tui/src/key_hint.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 515419ee04..f277f07384 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -78,6 +78,7 @@ impl From<&KeyBinding> for Span<'static> { let modifiers = modifiers_to_string(*modifiers); let key = match key { KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), KeyCode::Up => "↑".to_string(), KeyCode::Down => "↓".to_string(), KeyCode::Left => "←".to_string(), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index f5854d5545..46aaba8644 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -66,11 +66,18 @@ impl Overlay { const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); +const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); +const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); +const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); +const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); +const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); +const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); @@ -234,20 +241,33 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - e if KEY_UP.is_press(e) => { + e if KEY_UP.is_press(e) || KEY_K.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - e if KEY_DOWN.is_press(e) => { + e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - e if KEY_PAGE_UP.is_press(e) => { + e if KEY_PAGE_UP.is_press(e) + || KEY_SHIFT_SPACE.is_press(e) + || KEY_CTRL_B.is_press(e) => + { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } - e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { + e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(page_height); } + e if KEY_CTRL_D.is_press(e) => { + let area = self.content_area(tui.terminal.viewport_area); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_add(half_page); + } + e if KEY_CTRL_U.is_press(e) => { + let area = self.content_area(tui.terminal.viewport_area); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_sub(half_page); + } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; } From ac3237721eb6eb89760f0ae529144f581f3affa9 Mon Sep 17 00:00:00 2001 From: Job Chong Date: Wed, 10 Dec 2025 02:28:41 +0800 Subject: [PATCH 102/159] Fix: gracefully error out for unsupported images (#7478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for #7459 ## What Since codex errors out for unsupported images, stop attempting to base64/attach them and instead emit a clear placeholder when the file isn’t a supported image MIME. ## Why Local uploads for unsupported formats (e.g., SVG/GIF/etc.) were dead-ending after decode failures because of the 400 retry loop. Users now get an explicit “cannot attach … unsupported image format …” response. ## How Replace the fallback read/encode path with MIME detection that bails out for non-image or unsupported image types, returning a consistent placeholder. Unreadable and invalid images still produce their existing error placeholders. --- codex-rs/Cargo.lock | 1 - codex-rs/protocol/Cargo.toml | 1 - codex-rs/protocol/src/models.rs | 86 +++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2dde6c07cd..0b38db1eb4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1491,7 +1491,6 @@ name = "codex-protocol" version = "0.0.0" dependencies = [ "anyhow", - "base64", "codex-git", "codex-utils-image", "icu_decimal", diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 08f8375357..46f030c60a 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] codex-git = { workspace = true } -base64 = { workspace = true } codex-utils-image = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f93c157b7c..9f66d08dca 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -use base64::Engine; use codex_utils_image::load_and_resize_to_fit; use mcp_types::CallToolResult; use mcp_types::ContentBlock; @@ -175,6 +174,16 @@ fn invalid_image_error_placeholder( } } +fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem { + ContentItem::InputText { + text: format!( + "Codex cannot attach image at `{}`: unsupported image format `{}`.", + path.display(), + mime + ), + } +} + impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { @@ -285,37 +294,20 @@ impl From> for ResponseInputItem { } else if err.is_invalid_image() { invalid_image_error_placeholder(&path, &err) } else { - match std::fs::read(&path) { - Ok(bytes) => { - let Some(mime_guess) = mime_guess::from_path(&path).first() - else { - return local_image_error_placeholder( - &path, - "unsupported MIME type (unknown)", - ); - }; - let mime = mime_guess.essence_str().to_owned(); - if !mime.starts_with("image/") { - return local_image_error_placeholder( - &path, - format!("unsupported MIME type `{mime}`"), - ); - } - let encoded = - base64::engine::general_purpose::STANDARD.encode(bytes); - ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - } - } - Err(read_err) => { - tracing::warn!( - "Skipping image {} – could not read file: {}", - path.display(), - read_err - ); - local_image_error_placeholder(&path, &read_err) - } + let Some(mime_guess) = mime_guess::from_path(&path).first() else { + return local_image_error_placeholder( + &path, + "unsupported MIME type (unknown)", + ); + }; + let mime = mime_guess.essence_str().to_owned(); + if !mime.starts_with("image/") { + return local_image_error_placeholder( + &path, + format!("unsupported MIME type `{mime}`"), + ); } + unsupported_image_error_placeholder(&path, &mime) } } }, @@ -823,4 +815,36 @@ mod tests { Ok(()) } + + #[test] + fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> { + let dir = tempdir()?; + let svg_path = dir.path().join("example.svg"); + std::fs::write( + &svg_path, + br#" +"#, + )?; + + let item = ResponseInputItem::from(vec![UserInput::LocalImage { + path: svg_path.clone(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!(content.len(), 1); + let expected = format!( + "Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.", + svg_path.display() + ); + match &content[0] { + ContentItem::InputText { text } => assert_eq!(text, &expected), + other => panic!("expected placeholder text but found {other:?}"), + } + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } } From 7836aeddae57bb49dda4b1deb1445df2144cabee Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 9 Dec 2025 18:36:58 +0000 Subject: [PATCH 103/159] feat: shell snapshotting (#7641) --- codex-rs/apply-patch/src/lib.rs | 9 +- codex-rs/core/src/codex.rs | 28 +- codex-rs/core/src/environment_context.rs | 15 +- codex-rs/core/src/features.rs | 8 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/shell.rs | 43 ++ codex-rs/core/src/shell_snapshot.rs | 453 ++++++++++++++++++ codex-rs/core/src/state/service.rs | 2 +- .../core/src/tools/handlers/apply_patch.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 7 +- .../core/src/tools/handlers/unified_exec.rs | 56 ++- codex-rs/core/src/tools/registry.rs | 4 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 226 +++++++++ 14 files changed, 807 insertions(+), 48 deletions(-) create mode 100644 codex-rs/core/src/shell_snapshot.rs create mode 100644 codex-rs/core/tests/suite/shell_snapshot.rs diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 28dc14eb02..fe4fe584dc 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -112,7 +112,7 @@ fn classify_shell_name(shell: &str) -> Option { fn classify_shell(shell: &str, flag: &str) -> Option { classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if flag == "-lc" => Some(ApplyPatchShell::Unix), + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) } @@ -1097,6 +1097,13 @@ mod tests { assert_match(&heredoc_script(""), None); } + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + #[test] fn test_heredoc_applypatch() { let args = strs_to_strings(&[ diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8060929149..042ae1a37a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -109,6 +109,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; +use crate::shell_snapshot::ShellSnapshot; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -510,7 +511,6 @@ impl Session { // - load history metadata let rollout_fut = RolloutRecorder::new(&config, rollout_params); - let default_shell = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); let auth_statuses_fut = compute_auth_statuses( config.mcp_servers.iter(), @@ -572,7 +572,14 @@ impl Session { config.active_profile.clone(), ); + let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. + if config.features.enabled(Feature::ShellSnapshot) { + default_shell.shell_snapshot = + ShellSnapshot::try_new(&config.codex_home, &default_shell) + .await + .map(Arc::new); + } let state = SessionState::new(session_configuration.clone()); let services = SessionServices { @@ -581,7 +588,7 @@ impl Session { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), - user_shell: default_shell, + user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, @@ -799,14 +806,16 @@ impl Session { ) -> Option { let prev = previous?; - let prev_context = EnvironmentContext::from(prev.as_ref()); - let next_context = EnvironmentContext::from(next); + let shell = self.user_shell(); + let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); + let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); if prev_context.equals_except_shell(&next_context) { return None; } Some(ResponseItem::from(EnvironmentContext::diff( prev.as_ref(), next, + shell.as_ref(), ))) } @@ -1156,6 +1165,7 @@ impl Session { pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(3); + let shell = self.user_shell(); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } @@ -1172,7 +1182,7 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - self.user_shell().clone(), + shell.as_ref().clone(), ))); items } @@ -1447,8 +1457,8 @@ impl Session { &self.services.notifier } - pub(crate) fn user_shell(&self) -> &shell::Shell { - &self.services.user_shell + pub(crate) fn user_shell(&self) -> Arc { + Arc::clone(&self.services.user_shell) } fn show_raw_agent_reasoning(&self) -> bool { @@ -2878,7 +2888,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), @@ -2960,7 +2970,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 56e7f6cadb..54756bda2d 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,7 +6,6 @@ use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; -use crate::shell::default_user_shell; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -95,7 +94,7 @@ impl EnvironmentContext { && self.writable_roots == *writable_roots } - pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { @@ -111,18 +110,15 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, default_user_shell()) + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) } -} -impl From<&TurnContext> for EnvironmentContext { - fn from(turn_context: &TurnContext) -> Self { + pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - // Shell is not configurable from turn to turn - default_user_shell(), + shell.clone(), ) } } @@ -201,6 +197,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, } } @@ -338,6 +335,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), + shell_snapshot: None, }, ); let context2 = EnvironmentContext::new( @@ -347,6 +345,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), + shell_snapshot: None, }, ); diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 43a89480f4..d0b8e7e8da 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -60,6 +60,8 @@ pub enum Feature { ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). Skills, + /// Experimental shell snapshotting. + ShellSnapshot, } impl Feature { @@ -359,4 +361,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellSnapshot, + key: "shell_snapshot", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 59dac84d26..cd0ff497ab 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -72,6 +72,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod shell_snapshot; pub mod skills; pub mod spawn; pub mod terminal; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 2338f41cd4..608d806323 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; +use std::sync::Arc; + +use crate::shell_snapshot::ShellSnapshot; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -15,6 +18,8 @@ pub enum ShellType { pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, + #[serde(skip_serializing, skip_deserializing, default)] + pub(crate) shell_snapshot: Option>, } impl Shell { @@ -58,6 +63,33 @@ impl Shell { } } } + + pub(crate) fn wrap_command_with_snapshot(&self, command: &[String]) -> Vec { + let Some(snapshot) = &self.shell_snapshot else { + return command.to_vec(); + }; + + if command.is_empty() { + return command.to_vec(); + } + + match self.shell_type { + ShellType::Zsh | ShellType::Bash | ShellType::Sh => { + let mut args = self.derive_exec_args(". \"$0\" && exec \"$@\"", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::PowerShell => { + let mut args = + self.derive_exec_args("param($snapshot) . $snapshot; & @args", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::Cmd => command.to_vec(), + } + } } #[cfg(unix)] @@ -134,6 +166,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, + shell_snapshot: None, }) } @@ -143,6 +176,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, + shell_snapshot: None, }) } @@ -152,6 +186,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, + shell_snapshot: None, }) } @@ -167,6 +202,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, + shell_snapshot: None, }) } @@ -176,6 +212,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, + shell_snapshot: None, }) } @@ -184,11 +221,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), + shell_snapshot: None, } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), + shell_snapshot: None, } } } @@ -413,6 +452,7 @@ mod tests { let test_bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_eq!( test_bash_shell.derive_exec_args("echo hello", false), @@ -426,6 +466,7 @@ mod tests { let test_zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_eq!( test_zsh_shell.derive_exec_args("echo hello", false), @@ -439,6 +480,7 @@ mod tests { let test_powershell_shell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_eq!( test_powershell_shell.derive_exec_args("echo hello", false), @@ -465,6 +507,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), + shell_snapshot: None, } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs new file mode 100644 index 0000000000..4df54997b7 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot.rs @@ -0,0 +1,453 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell::get_shell; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use tokio::fs; +use tokio::process::Command; +use tokio::time::timeout; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellSnapshot { + pub path: PathBuf, +} + +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); + +impl ShellSnapshot { + pub async fn try_new(codex_home: &Path, shell: &Shell) -> Option { + let extension = match shell.shell_type { + ShellType::PowerShell => "ps1", + _ => "sh", + }; + let path = + codex_home + .join("shell_snapshots") + .join(format!("{}.{}", Uuid::new_v4(), extension)); + match write_shell_snapshot(shell.shell_type.clone(), &path).await { + Ok(path) => { + tracing::info!("Shell snapshot successfully created: {}", path.display()); + Some(Self { path }) + } + Err(err) => { + tracing::warn!( + "Failed to create shell snapshot for {}: {err:?}", + shell.name() + ); + None + } + } + } +} + +impl Drop for ShellSnapshot { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + tracing::warn!( + "Failed to delete shell snapshot at {:?}: {err:?}", + self.path + ); + } + } +} + +pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { + if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { + bail!("Shell snapshot not supported yet for {shell_type:?}"); + } + let shell = get_shell(shell_type.clone(), None) + .with_context(|| format!("No available shell for {shell_type:?}"))?; + + let raw_snapshot = capture_snapshot(&shell).await?; + let snapshot = strip_snapshot_preamble(&raw_snapshot)?; + + if let Some(parent) = output_path.parent() { + let parent_display = parent.display(); + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create snapshot parent {parent_display}"))?; + } + + let snapshot_path = output_path.display(); + fs::write(output_path, snapshot) + .await + .with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?; + + Ok(output_path.to_path_buf()) +} + +async fn capture_snapshot(shell: &Shell) -> Result { + let shell_type = shell.shell_type.clone(); + match shell_type { + ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, + ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), + } +} + +fn strip_snapshot_preamble(snapshot: &str) -> Result { + let marker = "# Snapshot file"; + let Some(start) = snapshot.find(marker) else { + bail!("Snapshot output missing marker {marker}"); + }; + + Ok(snapshot[start..].to_string()) +} + +async fn run_shell_script(shell: &Shell, script: &str) -> Result { + run_shell_script_with_timeout(shell, script, SNAPSHOT_TIMEOUT).await +} + +async fn run_shell_script_with_timeout( + shell: &Shell, + script: &str, + snapshot_timeout: Duration, +) -> Result { + let args = shell.derive_exec_args(script, true); + let shell_name = shell.name(); + + // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() + // returns a ref of handler. + let mut handler = Command::new(&args[0]); + handler.args(&args[1..]); + handler.kill_on_drop(true); + let output = timeout(snapshot_timeout, handler.output()) + .await + .map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))? + .with_context(|| format!("Failed to execute {shell_name}"))?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Snapshot command exited with status {status}: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn zsh_snapshot_script() -> &'static str { + r##"print '# Snapshot file' +print '# Unset all aliases to avoid conflicts with functions' +print 'unalias -a 2>/dev/null || true' +print '# Functions' +functions +print '' +setopt_count=$(setopt | wc -l | tr -d ' ') +print "# setopts $setopt_count" +setopt | sed 's/^/setopt /' +print '' +alias_count=$(alias -L | wc -l | tr -d ' ') +print "# aliases $alias_count" +alias -L +print '' +export_count=$(export -p | wc -l | tr -d ' ') +print "# exports $export_count" +export -p +"## +} + +fn bash_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +declare -f +echo '' +bash_opts=$(set -o | awk '$2=="on"{print $1}') +bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ') +echo "# setopts $bash_opt_count" +if [ -n "$bash_opts" ]; then + printf 'set -o %s\n' $bash_opts +fi +echo '' +alias_count=$(alias -p | wc -l | tr -d ' ') +echo "# aliases $alias_count" +alias -p +echo '' +export_count=$(export -p | wc -l | tr -d ' ') +echo "# exports $export_count" +export -p +"## +} + +fn sh_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +if command -v typeset >/dev/null 2>&1; then + typeset -f +elif command -v declare >/dev/null 2>&1; then + declare -f +fi +echo '' +if set -o >/dev/null 2>&1; then + sh_opts=$(set -o | awk '$2=="on"{print $1}') + sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ') + echo "# setopts $sh_opt_count" + if [ -n "$sh_opts" ]; then + printf 'set -o %s\n' $sh_opts + fi +else + echo '# setopts 0' +fi +echo '' +if alias >/dev/null 2>&1; then + alias_count=$(alias | wc -l | tr -d ' ') + echo "# aliases $alias_count" + alias + echo '' +else + echo '# aliases 0' +fi +if export -p >/dev/null 2>&1; then + export_count=$(export -p | wc -l | tr -d ' ') + echo "# exports $export_count" + export -p +else + export_count=$(env | wc -l | tr -d ' ') + echo "# exports $export_count" + env | sort | while IFS='=' read -r key value; do + escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") + printf "export %s='%s'\n" "$key" "$escaped" + done +fi +"## +} + +fn powershell_snapshot_script() -> &'static str { + r##"$ErrorActionPreference = 'Stop' +Write-Output '# Snapshot file' +Write-Output '# Unset all aliases to avoid conflicts with functions' +Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue' +Write-Output '# Functions' +Get-ChildItem Function: | ForEach-Object { + "function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition +} +Write-Output '' +$aliases = Get-Alias +Write-Output ("# aliases " + $aliases.Count) +$aliases | ForEach-Object { + "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition +} +Write-Output '' +$envVars = Get-ChildItem Env: +Write-Output ("# exports " + $envVars.Count) +$envVars | ForEach-Object { + $escaped = $_.Value -replace "'", "''" + "`$env:{0}='{1}'" -f $_.Name, $escaped +} +"## +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; + #[cfg(target_os = "linux")] + use std::process::Command as StdCommand; + use std::sync::Arc; + use tempfile::tempdir; + + #[cfg(not(target_os = "windows"))] + fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!( + snapshot.contains("PATH"), + "snapshot should capture a PATH export" + ); + assert!(snapshot.contains("setopts ")); + } + + async fn get_snapshot(shell_type: ShellType) -> Result { + let dir = tempdir()?; + let path = dir.path().join("snapshot.sh"); + write_shell_snapshot(shell_type, &path).await?; + let content = fs::read_to_string(&path).await?; + Ok(content) + } + + #[test] + fn strip_snapshot_preamble_removes_leading_output() { + let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; + let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); + assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); + } + + #[test] + fn strip_snapshot_preamble_requires_marker() { + let result = strip_snapshot_preamble("missing header"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn wrap_command_with_snapshot_wraps_bash_shell() { + let snapshot_path = PathBuf::from("/tmp/snapshot.sh"); + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path.clone(), + })), + }; + let original_command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + let mut expected = shell.derive_exec_args(". \"$0\" && exec \"$@\"", false); + expected.push(snapshot_path.to_string_lossy().to_string()); + expected.extend_from_slice(&original_command); + + assert_eq!(wrapped, expected); + } + + #[test] + fn wrap_command_with_snapshot_preserves_cmd_shell() { + let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); + let shell = Shell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path, + })), + }; + let original_command = vec![ + "cmd".to_string(), + "/c".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + assert_eq!(wrapped, original_command); + } + + #[cfg(unix)] + #[tokio::test] + async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { + let dir = tempdir()?; + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, + }; + + let snapshot = ShellSnapshot::try_new(dir.path(), &shell) + .await + .expect("snapshot should be created"); + let path = snapshot.path.clone(); + assert!(path.exists()); + + drop(snapshot); + + assert!(!path.exists()); + + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { + use std::process::Stdio; + let dir = tempdir()?; + let shell_path = dir.path().join("hanging-shell.sh"); + let pid_path = dir.path().join("pid"); + + let script = format!( + "#!/bin/sh\n\ + echo $$ > {}\n\ + sleep 30\n", + pid_path.display() + ); + fs::write(&shell_path, script).await?; + let mut permissions = std::fs::metadata(&shell_path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&shell_path, permissions)?; + + let shell = Shell { + shell_type: ShellType::Sh, + shell_path, + shell_snapshot: None, + }; + + let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500)) + .await + .expect_err("snapshot shell should time out"); + assert!( + err.to_string().contains("timed out"), + "expected timeout error, got {err:?}" + ); + + let pid = fs::read_to_string(&pid_path) + .await + .expect("snapshot shell writes its pid before timing out") + .trim() + .parse::()?; + + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + assert!( + !kill_status.success(), + "timed out snapshot shell should be terminated" + ); + + Ok(()) + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_zsh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Zsh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_bash_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Bash).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_sh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Sh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "windows")] + #[ignore] + #[tokio::test] + async fn windows_powershell_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::PowerShell).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + Ok(()) + } +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a35720a9bf..7387bcedae 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -18,7 +18,7 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, - pub(crate) user_shell: crate::shell::Shell, + pub(crate) user_shell: Arc, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 4a28619c76..5b8a04b388 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -46,7 +46,7 @@ impl ToolHandler for ApplyPatchHandler { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { true } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7d13c90fa0..98bd883d13 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -76,7 +76,7 @@ impl ToolHandler for ShellHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { match &invocation.payload { ToolPayload::Function { arguments } => { serde_json::from_str::(arguments) @@ -148,7 +148,7 @@ impl ToolHandler for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let ToolPayload::Function { arguments } = &invocation.payload else { return true; }; @@ -307,18 +307,21 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_safe(&zsh_shell, "ls -la"); let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_safe(&powershell, "ls -Name"); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 66cf624a6c..c7230e54d7 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,12 +1,10 @@ -use std::path::PathBuf; - use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; use crate::protocol::ExecOutputStream; -use crate::shell::default_user_shell; +use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -24,6 +22,8 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; pub struct UnifiedExecHandler; @@ -34,8 +34,8 @@ struct ExecCommandArgs { workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] @@ -66,10 +66,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - #[async_trait] impl ToolHandler for UnifiedExecHandler { fn kind(&self) -> ToolKind { @@ -83,7 +79,7 @@ impl ToolHandler for UnifiedExecHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) = &invocation.payload else { @@ -93,7 +89,7 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms); + let command = get_command(¶ms, invocation.session.user_shell()); !is_known_safe_command(&command) } @@ -130,9 +126,10 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args); + let command_for_intercept = get_command(&args, session.user_shell()); let ExecCommandArgs { workdir, + login, yield_time_ms, max_output_tokens, with_escalated_permissions, @@ -159,7 +156,7 @@ impl ToolHandler for UnifiedExecHandler { let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); if let Some(output) = intercept_apply_patch( - &command, + &command_for_intercept, &cwd, Some(yield_time_ms), context.session.as_ref(), @@ -180,6 +177,14 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); + let command = if login.is_none() { + context + .session + .user_shell() + .wrap_command_with_snapshot(&command_for_intercept) + } else { + command_for_intercept + }; let emitter = ToolEmitter::unified_exec( &command, cwd.clone(), @@ -254,14 +259,15 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs) -> Vec { - let shell = if let Some(shell_str) = &args.shell { - get_shell_by_model_provided_path(&PathBuf::from(shell_str)) - } else { - default_user_shell() - }; +fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { + if let Some(shell_str) = &args.shell { + let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); + shell.shell_snapshot = None; + return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)); + } - shell.derive_exec_args(&args.cmd, args.login) + let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); + session_shell.derive_exec_args(&args.cmd, use_login_shell) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -296,6 +302,8 @@ fn format_response(response: &UnifiedExecResponse) -> String { #[cfg(test)] mod tests { use super::*; + use crate::shell::default_user_shell; + use std::sync::Arc; #[test] fn test_get_command_uses_default_shell_when_unspecified() { @@ -306,7 +314,7 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -321,7 +329,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -335,7 +343,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -349,7 +357,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f35ff06315..9b33e84b76 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,7 +30,7 @@ pub trait ToolHandler: Send + Sync { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { false } @@ -110,7 +110,7 @@ impl ToolRegistry { let output_cell = &output_cell; let invocation = invocation; async move { - if handler.is_mutating(&invocation) { + if handler.is_mutating(&invocation).await { tracing::trace!("waiting for tool gate"); invocation.turn.tool_call_gate.wait_ready().await; tracing::trace!("tool gate released"); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2112cbb7aa..29cc3ffb19 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -49,6 +49,7 @@ mod rollout_list_find; mod seatbelt; mod shell_command; mod shell_serialization; +mod shell_snapshot; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs new file mode 100644 index 0000000000..f50e153ddc --- /dev/null +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -0,0 +1,226 @@ +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Debug)] +struct SnapshotRun { + begin: ExecCommandBeginEvent, + end: ExecCommandEndEvent, + snapshot_path: PathBuf, + snapshot_content: String, + codex_home: PathBuf, +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command(command: &str) -> Result { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let args = json!({ + "cmd": command, + "yield_time_ms": 1000, + }); + let call_id = "shell-snapshot-exec"; + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let codex_home = test.home.path().to_path_buf(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run unified exec with shell snapshot".into(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let snapshot_arg = begin + .command + .iter() + .find(|arg| arg.contains("shell_snapshots")) + .expect("command includes shell snapshot path") + .to_owned(); + let snapshot_path = PathBuf::from(&snapshot_arg); + let snapshot_content = fs::read_to_string(&snapshot_path).await?; + + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + Ok(SnapshotRun { + begin, + end, + snapshot_path, + snapshot_content, + codex_home, + }) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n") +} + +fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("setopts ")); + assert!( + snapshot.contains("PATH"), + "snapshot should include PATH exports; snapshot={snapshot:?}" + ); +} + +#[cfg_attr(not(target_os = "linux"), ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-linux"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +#[cfg_attr(not(target_os = "macos"), ignore)] +#[cfg_attr( + target_os = "macos", + ignore = "requires unrestricted networking on macOS" +)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn macos_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-macos"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-macos"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +// #[cfg_attr(not(target_os = "windows"), ignore)] +#[ignore] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "Write-Output snapshot-windows"; + let run = run_snapshot_command(command).await?; + + let snapshot_index = run + .begin + .command + .iter() + .position(|arg| arg.contains("shell_snapshots")) + .expect("snapshot argument exists"); + assert!(run.begin.command.iter().any(|arg| arg == "-NoProfile")); + assert!( + run.begin + .command + .iter() + .any(|arg| arg == "param($snapshot) . $snapshot; & @args") + ); + assert!(snapshot_index > 0); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert!(run.snapshot_content.contains("# Snapshot file")); + assert!(run.snapshot_content.contains("# aliases ")); + assert!(run.snapshot_content.contains("# exports ")); + assert_eq!( + normalize_newlines(&run.end.stdout).trim(), + "snapshot-windows" + ); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} From 05e546ee1f7a2cd92d83872e32ddb52a0892938c Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Tue, 9 Dec 2025 13:23:14 -0800 Subject: [PATCH 104/159] fix more typos in execpolicy.md (#7787) --- docs/execpolicy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/execpolicy.md b/docs/execpolicy.md index b5167a1eb4..a2dc6d9add 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -14,10 +14,10 @@ Whitelisted commands will no longer require your permission to run in current an Under the hood, when you approve and whitelist a command, codex will edit `~/.codex/policy/default.execpolicy`. -### Editing `.codexpolicy` files +### Editing `.execpolicy` files 1. Create a policy directory: `mkdir -p ~/.codex/policy`. -2. Add one or more `.codexpolicy` files in that folder. Codex automatically loads every `.codexpolicy` file in there on startup. +2. Add one or more `.execpolicy` files in that folder. Codex automatically loads every `.execpolicy` file in there on startup. 3. Write `prefix_rule` entries to describe the commands you want to allow, prompt, or block: ```starlark From 225a5f7ffb9872673df35b7978b5e2bacdc12190 Mon Sep 17 00:00:00 2001 From: Bryant Rolfe Date: Tue, 9 Dec 2025 14:41:10 -0800 Subject: [PATCH 105/159] Add vim-style navigation for CLI option selection (#7784) ## Summary Support "j" and "k" keys as aliases for "down" and "up" so vim users feel loved. Only support these keys when the selection is not searchable. ## Testing - env -u NO_COLOR TERM=xterm-256color cargo test -p codex-tui ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_693771b53bc8833088669060dfac2083) --- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 26a32a42e1..d23fd8ed3b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -284,6 +284,11 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_up(), KeyEvent { code: KeyCode::Down, .. @@ -298,6 +303,11 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^N */ => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_down(), KeyEvent { code: KeyCode::Backspace, .. From 0c8828c5e298359ba50ba1e9c840400614afcd45 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 9 Dec 2025 16:23:53 -0800 Subject: [PATCH 106/159] feat(tui2): add feature-flagged tui2 frontend (#7793) Introduce a new codex-tui2 crate that re-exports the existing interactive TUI surface and delegates run_main directly to codex-tui. This keeps behavior identical while giving tui2 its own crate for future viewport work. Wire the codex CLI to select the frontend via the tui2 feature flag. When the merged CLI overrides include features.tui2=true (e.g. via --enable tui2), interactive runs are routed through codex_tui2::run_main; otherwise they continue to use the original codex_tui::run_main. Register Feature::Tui2 in the core feature registry and add the tui2 crate and dependency entries so the new frontend builds alongside the existing TUI. This is a stub that only wires up the feature flag for this. image --- codex-rs/Cargo.lock | 13 +++++++++++ codex-rs/Cargo.toml | 2 ++ codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 43 +++++++++++++++++++++++++++++++++-- codex-rs/core/src/features.rs | 8 +++++++ codex-rs/tui2/Cargo.toml | 29 +++++++++++++++++++++++ codex-rs/tui2/src/lib.rs | 24 +++++++++++++++++++ codex-rs/tui2/src/main.rs | 32 ++++++++++++++++++++++++++ docs/config.md | 21 +++++++++-------- 9 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 codex-rs/tui2/Cargo.toml create mode 100644 codex-rs/tui2/src/lib.rs create mode 100644 codex-rs/tui2/src/main.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0b38db1eb4..aa1f72b4b4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1040,6 +1040,7 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", + "codex-tui2", "codex-windows-sandbox", "ctor 0.5.0", "libc", @@ -1637,6 +1638,18 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-tui2" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-arg0", + "codex-common", + "codex-core", + "codex-tui", +] + [[package]] name = "codex-utils-cache" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9f55f67ce3..bd62c72d5f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -34,6 +34,7 @@ members = [ "stdio-to-uds", "otel", "tui", + "tui2", "utils/git", "utils/cache", "utils/image", @@ -88,6 +89,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } +codex-tui2 = { path = "tui2" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6c80a12595..84e6e9acaf 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } +codex-tui2 = { workspace = true } ctor = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6cff73e86d..c3788f83f4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -25,6 +25,7 @@ use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::update_action::UpdateAction; +use codex_tui2 as tui2; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -37,6 +38,11 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::features::Feature; +use codex_core::features::FeatureOverrides; +use codex_core::features::Features; use codex_core::features::is_known_feature_key; /// Codex CLI @@ -444,7 +450,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { @@ -499,7 +505,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() all, config_overrides, ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { @@ -650,6 +656,39 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } +/// Run the interactive Codex TUI, dispatching to either the legacy implementation or the +/// experimental TUI v2 shim based on feature flags resolved from config. +async fn run_interactive_tui( + interactive: TuiCli, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { + if is_tui2_enabled(&interactive).await? { + tui2::run_main(interactive, codex_linux_sandbox_exe).await + } else { + codex_tui::run_main(interactive, codex_linux_sandbox_exe).await + } +} + +/// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. +/// +/// This performs a lightweight config load (honoring the same precedence as the lower-level TUI +/// bootstrap: `$CODEX_HOME`, config.toml, profile, and CLI `-c` overrides) solely to decide which +/// TUI frontend to launch. The full configuration is still loaded later by the interactive TUI. +async fn is_tui2_enabled(cli: &TuiCli) -> std::io::Result { + let raw_overrides = cli.config_overrides.raw_overrides.clone(); + let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; + let cli_kv_overrides = overrides_cli + .parse_overrides() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + let codex_home = find_codex_home()?; + let config_toml = load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await?; + let config_profile = config_toml.get_config_profile(cli.config_profile.clone())?; + let overrides = FeatureOverrides::default(); + let features = Features::from_config(&config_toml, &config_profile, overrides); + Ok(features.enabled(Feature::Tui2)) +} + /// Build the final `TuiCli` for a `codex resume` invocation. fn finalize_resume_interactive( mut interactive: TuiCli, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d0b8e7e8da..d714f8e85e 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -62,6 +62,8 @@ pub enum Feature { Skills, /// Experimental shell snapshotting. ShellSnapshot, + /// Experimental TUI v2 (viewport) implementation. + Tui2, } impl Feature { @@ -367,4 +369,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Tui2, + key: "tui2", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml new file mode 100644 index 0000000000..fececb1503 --- /dev/null +++ b/codex-rs/tui2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "codex-tui2" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_tui2" +path = "src/lib.rs" + +[[bin]] +name = "codex-tui2" +path = "src/main.rs" + +[features] +# Keep feature surface aligned with codex-tui while tui2 delegates to it. +vt100-tests = [] +debug-logs = [] + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-arg0 = { workspace = true } +codex-common = { workspace = true } +codex-core = { workspace = true } +codex-tui = { workspace = true } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs new file mode 100644 index 0000000000..502efa76a0 --- /dev/null +++ b/codex-rs/tui2/src/lib.rs @@ -0,0 +1,24 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] +#![deny(clippy::disallowed_methods)] + +use std::path::PathBuf; + +pub use codex_tui::AppExitInfo; +pub use codex_tui::Cli; +pub use codex_tui::update_action; + +/// Entry point for the experimental TUI v2 crate. +/// +/// Currently this is a thin shim that delegates to the existing `codex-tui` +/// implementation so behavior and rendering remain identical while the new +/// viewport is developed behind a feature toggle. +pub async fn run_main( + cli: Cli, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { + #[allow(clippy::print_stdout)] // for now + { + println!("Note: You are running the experimental TUI v2 implementation."); + } + codex_tui::run_main(cli, codex_linux_sandbox_exe).await +} diff --git a/codex-rs/tui2/src/main.rs b/codex-rs/tui2/src/main.rs new file mode 100644 index 0000000000..b50d994d80 --- /dev/null +++ b/codex-rs/tui2/src/main.rs @@ -0,0 +1,32 @@ +use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; +use codex_common::CliConfigOverrides; +use codex_core::protocol::FinalOutput; +use codex_tui2::Cli; +use codex_tui2::run_main; + +#[derive(Parser, Debug)] +struct TopCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, + + #[clap(flatten)] + inner: Cli, +} + +fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + let top_cli = TopCli::parse(); + let mut inner = top_cli.inner; + inner + .config_overrides + .raw_overrides + .splice(0..0, top_cli.config_overrides.raw_overrides); + let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; + let token_usage = exit_info.token_usage; + if !token_usage.is_zero() { + println!("{}", FinalOutput::from(token_usage)); + } + Ok(()) + }) +} diff --git a/docs/config.md b/docs/config.md index 08ff2aa349..4e78da7ac6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,16 +39,17 @@ web_search_request = true # allow the model to request web searches Supported features: -| Key | Default | Stage | Description | -| ----------------------------------------- | :-----: | ------------ | ---------------------------------------------------- | -| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | -| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | -| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | -| `view_image_tool` | true | Stable | Include the `view_image` tool | -| `web_search_request` | false | Stable | Allow the model to issue web searches | -| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | -| `ghost_commit` | false | Experimental | Create a ghost commit each turn | -| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| Key | Default | Stage | Description | +| ----------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | Notes: From fa4cac1e6bcf7c4a407cb29cf65fab0b4468dd6e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 9 Dec 2025 17:37:52 -0800 Subject: [PATCH 107/159] fix: introduce AbsolutePathBuf and resolve relative paths in config.toml (#7796) This PR attempts to solve two problems by introducing a `AbsolutePathBuf` type with a special deserializer: - `AbsolutePathBuf` attempts to be a generally useful abstraction, as it ensures, by constructing, that it represents a value that is an absolute, normalized path, which is a stronger guarantee than an arbitrary `PathBuf`. - Values in `config.toml` that can be either an absolute or relative path should be resolved against the folder containing the `config.toml` in the relative path case. This PR makes this easy to support: the main cost is ensuring `AbsolutePathBufGuard` is used inside `deserialize_config_toml_with_base()`. While `AbsolutePathBufGuard` may seem slightly distasteful because it relies on thread-local storage, this seems much cleaner to me than using than my various experiments with https://docs.rs/serde/latest/serde/de/trait.DeserializeSeed.html. Further, since the `deserialize()` method from the `Deserialize` trait is not async, we do not really have to worry about the deserialization work being spread across multiple threads in a way that would interfere with `AbsolutePathBufGuard`. To start, this PR introduces the use of `AbsolutePathBuf` in `OtelTlsConfig`. Note how this simplifies `otel_provider.rs` because it no longer requires `settings.codex_home` to be threaded through. Furthermore, this sets us up better for a world where multiple `config.toml` files from different folders could be loaded and then merged together, as the absolutifying of the paths must be done against the correct parent folder. --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 19 +-- codex-rs/core/src/config/mod.rs | 39 ++++-- codex-rs/core/src/config/types.rs | 9 +- codex-rs/otel/Cargo.toml | 1 + codex-rs/otel/src/config.rs | 8 +- codex-rs/otel/src/otel_provider.rs | 49 +++----- codex-rs/utils/absolute-path/Cargo.toml | 17 +++ codex-rs/utils/absolute-path/src/lib.rs | 152 ++++++++++++++++++++++++ 10 files changed, 246 insertions(+), 62 deletions(-) create mode 100644 codex-rs/utils/absolute-path/Cargo.toml create mode 100644 codex-rs/utils/absolute-path/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aa1f72b4b4..58ba4f2a96 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1157,6 +1157,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-absolute-path", "codex-utils-pty", "codex-utils-readiness", "codex-utils-string", @@ -1464,6 +1465,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-protocol", + "codex-utils-absolute-path", "eventsource-stream", "http", "opentelemetry", @@ -1650,6 +1652,16 @@ dependencies = [ "codex-tui", ] +[[package]] +name = "codex-utils-absolute-path" +version = "0.0.0" +dependencies = [ + "path-absolutize", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-utils-cache" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bd62c72d5f..a2521e3bda 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -35,6 +35,7 @@ members = [ "otel", "tui", "tui2", + "utils/absolute-path", "utils/git", "utils/cache", "utils/image", @@ -90,6 +91,7 @@ codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-tui2 = { path = "tui2" } +codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index f24cc9bc67..2bc281d903 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "codex-core" -version.workspace = true edition.workspace = true license.workspace = true +name = "codex-core" +version.workspace = true [lib] doctest = false @@ -18,12 +18,12 @@ askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } -chrono = { workspace = true, features = ["serde"] } chardetng = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } -codex-api = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } @@ -31,14 +31,15 @@ codex-keyring-store = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } dunce = { workspace = true } -env-flags = { workspace = true } encoding_rs = { workspace = true } +env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } @@ -46,8 +47,10 @@ indexmap = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } +once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } +regex = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } @@ -58,9 +61,6 @@ sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } -url = { workspace = true } -once_cell = { workspace = true } -regex = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" test-log = { workspace = true } @@ -84,6 +84,7 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } +url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } @@ -94,9 +95,9 @@ test-support = [] [target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } landlock = { workspace = true } seccompiler = { workspace = true } -keyring = { workspace = true, features = ["linux-native-async-persistent"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index df7637a301..8db08c55a2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -40,6 +40,7 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; use dunce::canonicalize; use serde::Deserialize; @@ -299,9 +300,9 @@ impl Config { ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, &codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) @@ -319,9 +320,9 @@ pub async fn load_config_as_toml_with_cli_overrides( ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Ok(cfg) @@ -357,6 +358,18 @@ fn apply_overlays( base } +fn deserialize_config_toml_with_base( + root_value: TomlValue, + config_base_dir: &Path, +) -> std::io::Result { + // This guard ensures that any relative paths that is deserialized into an + // [AbsolutePathBuf] is resolved against `config_base_dir`. + let _guard = AbsolutePathBufGuard::new(config_base_dir); + root_value + .try_into() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { @@ -1852,10 +1865,11 @@ trust_level = "trusted" }; let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!( cfg.mcp_oauth_credentials_store, Some(OAuthCredentialsStoreMode::Keyring), @@ -1972,10 +1986,11 @@ trust_level = "trusted" ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); Ok(()) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 5e1b78aa7b..6648e288a5 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -3,13 +3,14 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use serde::Deserializer; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use wildmatch::WildMatchPattern; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; @@ -285,9 +286,9 @@ pub enum OtelHttpProtocol { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } /// Which OTEL exporter to use. diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 5ed6c09498..af8b72346d 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -21,6 +21,7 @@ otel = ["opentelemetry", "opentelemetry_sdk", "opentelemetry-otlp", "tonic"] [dependencies] chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-protocol = { workspace = true } eventsource-stream = { workspace = true } opentelemetry = { workspace = true, features = ["logs"], optional = true } diff --git a/codex-rs/otel/src/config.rs b/codex-rs/otel/src/config.rs index b6336b3a5c..652a1c97b2 100644 --- a/codex-rs/otel/src/config.rs +++ b/codex-rs/otel/src/config.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::path::PathBuf; +use codex_utils_absolute_path::AbsolutePathBuf; + #[derive(Clone, Debug)] pub struct OtelSettings { pub environment: String, @@ -20,9 +22,9 @@ pub enum OtelHttpProtocol { #[derive(Clone, Debug, Default)] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 5495db0ad3..92b1feaa18 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -2,6 +2,7 @@ use crate::config::OtelExporter; use crate::config::OtelHttpProtocol; use crate::config::OtelSettings; use crate::config::OtelTlsConfig; +use codex_utils_absolute_path::AbsolutePathBuf; use http::Uri; use opentelemetry::KeyValue; use opentelemetry_otlp::LogExporter; @@ -25,7 +26,6 @@ use std::error::Error; use std::fs; use std::io::ErrorKind; use std::io::{self}; -use std::path::Path; use std::path::PathBuf; use std::time::Duration; use tonic::metadata::MetadataMap; @@ -85,12 +85,7 @@ impl OtelProvider { .assume_http2(true); let tls_config = match tls.as_ref() { - Some(tls) => build_grpc_tls_config( - endpoint, - base_tls_config, - tls, - settings.codex_home.as_path(), - )?, + Some(tls) => build_grpc_tls_config(endpoint, base_tls_config, tls)?, None => base_tls_config, }; @@ -123,7 +118,7 @@ impl OtelProvider { .with_headers(headers.clone()); if let Some(tls) = tls.as_ref() { - let client = build_http_client(tls, settings.codex_home.as_path())?; + let client = build_http_client(tls)?; exporter_builder = exporter_builder.with_http_client(client); } @@ -149,7 +144,6 @@ fn build_grpc_tls_config( endpoint: &str, tls_config: ClientTlsConfig, tls: &OtelTlsConfig, - codex_home: &Path, ) -> Result> { let uri: Uri = endpoint.parse()?; let host = uri.host().ok_or_else(|| { @@ -161,14 +155,14 @@ fn build_grpc_tls_config( let mut config = tls_config.domain_name(host.to_owned()); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, _) = read_bytes(codex_home, path)?; + let (pem, _) = read_bytes(path)?; config = config.ca_certificate(TonicCertificate::from_pem(pem)); } match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (cert_pem, _) = read_bytes(codex_home, cert_path)?; - let (key_pem, _) = read_bytes(codex_home, key_path)?; + let (cert_pem, _) = read_bytes(cert_path)?; + let (key_pem, _) = read_bytes(key_path)?; config = config.identity(TonicIdentity::from_pem(cert_pem, key_pem)); } (Some(_), None) | (None, Some(_)) => { @@ -188,24 +182,20 @@ fn build_grpc_tls_config( /// `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses /// `futures_executor::block_on()` rather than tokio. When the async reqwest client's /// timeout calls `tokio::time::sleep()`, it panics with "no reactor running". -fn build_http_client( - tls: &OtelTlsConfig, - codex_home: &Path, -) -> Result> { +fn build_http_client(tls: &OtelTlsConfig) -> Result> { // Wrap in block_in_place because reqwest::blocking::Client creates its own // internal tokio runtime, which would panic if built directly from an async context. - tokio::task::block_in_place(|| build_http_client_inner(tls, codex_home)) + tokio::task::block_in_place(|| build_http_client_inner(tls)) } fn build_http_client_inner( tls: &OtelTlsConfig, - codex_home: &Path, ) -> Result> { let mut builder = reqwest::blocking::Client::builder() .timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, location) = read_bytes(codex_home, path)?; + let (pem, location) = read_bytes(path)?; let certificate = ReqwestCertificate::from_pem(pem.as_slice()).map_err(|error| { config_error(format!( "failed to parse certificate {}: {error}", @@ -220,8 +210,8 @@ fn build_http_client_inner( match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (mut cert_pem, cert_location) = read_bytes(codex_home, cert_path)?; - let (key_pem, key_location) = read_bytes(codex_home, key_path)?; + let (mut cert_pem, cert_location) = read_bytes(cert_path)?; + let (key_pem, key_location) = read_bytes(key_path)?; cert_pem.extend_from_slice(key_pem.as_slice()); let identity = ReqwestIdentity::from_pem(cert_pem.as_slice()).map_err(|error| { config_error(format!( @@ -264,25 +254,16 @@ fn read_timeout_env(var: &str) -> Option { Some(Duration::from_millis(parsed as u64)) } -fn read_bytes(base: &Path, provided: &PathBuf) -> Result<(Vec, PathBuf), Box> { - let resolved = resolve_config_path(base, provided); - match fs::read(&resolved) { - Ok(bytes) => Ok((bytes, resolved)), +fn read_bytes(path: &AbsolutePathBuf) -> Result<(Vec, PathBuf), Box> { + match fs::read(path) { + Ok(bytes) => Ok((bytes, path.to_path_buf())), Err(error) => Err(Box::new(io::Error::new( error.kind(), - format!("failed to read {}: {error}", resolved.display()), + format!("failed to read {}: {error}", path.display()), ))), } } -fn resolve_config_path(base: &Path, provided: &PathBuf) -> PathBuf { - if provided.is_absolute() { - provided.clone() - } else { - base.join(provided) - } -} - fn config_error(message: impl Into) -> Box { Box::new(io::Error::new(ErrorKind::InvalidData, message.into())) } diff --git a/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml new file mode 100644 index 0000000000..486051fcb4 --- /dev/null +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -0,0 +1,17 @@ + +[package] +name = "codex-utils-absolute-path" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +path-absolutize = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs new file mode 100644 index 0000000000..5ea77b2b52 --- /dev/null +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -0,0 +1,152 @@ +use path_absolutize::Absolutize; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; +use std::cell::RefCell; +use std::path::Display; +use std::path::Path; +use std::path::PathBuf; + +/// A path that is guaranteed to be absolute and normalized (though it is not +/// guaranteed to be canonicalized or exist on the filesystem). +/// +/// IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set +/// using `AbsolutePathBufGuard::new(base_path)`. If no base path is set, the +/// deserialization will fail unless the path being deserialized is already +/// absolute. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AbsolutePathBuf(PathBuf); + +impl AbsolutePathBuf { + pub fn resolve_path_against_base(path: P, base_path: B) -> std::io::Result + where + P: AsRef, + B: AsRef, + { + let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?; + Ok(Self(absolute_path.into_owned())) + } + + pub fn from_absolute_path

(path: P) -> std::io::Result + where + P: AsRef, + { + let absolute_path = path.as_ref().absolutize()?; + Ok(Self(absolute_path.into_owned())) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } + + pub fn to_path_buf(&self) -> PathBuf { + self.0.clone() + } + + pub fn display(&self) -> Display<'_> { + self.0.display() + } +} + +thread_local! { + static ABSOLUTE_PATH_BASE: RefCell> = const { RefCell::new(None) }; +} + +pub struct AbsolutePathBufGuard; + +impl AbsolutePathBufGuard { + pub fn new(base_path: &Path) -> Self { + ABSOLUTE_PATH_BASE.with(|cell| { + *cell.borrow_mut() = Some(base_path.to_path_buf()); + }); + Self + } +} + +impl Drop for AbsolutePathBufGuard { + fn drop(&mut self) { + ABSOLUTE_PATH_BASE.with(|cell| { + *cell.borrow_mut() = None; + }); + } +} + +impl<'de> Deserialize<'de> for AbsolutePathBuf { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let path = PathBuf::deserialize(deserializer)?; + ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() { + Some(base) => { + Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?) + } + None if path.is_absolute() => { + Self::from_absolute_path(path).map_err(SerdeError::custom) + } + None => Err(SerdeError::custom( + "AbsolutePathBuf deserialized without a base path", + )), + }) + } +} + +impl AsRef for AbsolutePathBuf { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl From for PathBuf { + fn from(path: AbsolutePathBuf) -> Self { + path.into_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn create_with_absolute_path_ignores_base_path() { + let base_dir = tempdir().expect("base dir"); + let absolute_dir = tempdir().expect("absolute dir"); + let base_path = base_dir.path(); + let absolute_path = absolute_dir.path().join("file.txt"); + let abs_path_buf = + AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path) + .expect("failed to create"); + assert_eq!(abs_path_buf.as_path(), absolute_path.as_path()); + } + + #[test] + fn relative_path_is_resolved_against_base_path() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir) + .expect("failed to create"); + assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path()); + } + + #[test] + fn guard_used_in_deserialization() { + let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); + let relative_path = "subdir/file.txt"; + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::(&format!(r#""{relative_path}""#)) + .expect("failed to deserialize") + }; + assert_eq!( + abs_path_buf.as_path(), + base_dir.join(relative_path).as_path() + ); + } +} From 893f5261eb620b9fd36ec61cfcae929ceb11b1cd Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 17:43:53 -0800 Subject: [PATCH 108/159] feat: support mcp in-session login (#7751) ### Summary * Added `mcpServer/oauthLogin` in app server for supporting in session MCP server login * Added `McpServerOauthLoginParams` and `McpServerOauthLoginResponse` to support above method with response returning the auth URL for consumer to open browser or display accordingly. * Added `McpServerOauthLoginCompletedNotification` which the app server would emit on MCP server login success or failure (i.e. timeout). * Refactored rmcp-client oath_login to have the ability on starting a auth server which the codex_message_processor uses for in-session auth. --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 31 ++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 126 ++++++++ codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/rmcp-client/src/lib.rs | 2 + .../rmcp-client/src/perform_oauth_login.rs | 283 ++++++++++++++---- 8 files changed, 392 insertions(+), 59 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 58ba4f2a96..9a3cd95dfa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "codex-file-search", "codex-login", "codex-protocol", + "codex-rmcp-client", "codex-utils-json-to-toml", "core_test_support", "mcp-types", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2858366739..c62acc8832 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServerOauthLogin => "mcpServer/oauth/login" { + params: v2::McpServerOauthLoginParams, + response: v2::McpServerOauthLoginResponse, + }, + McpServersList => "mcpServers/list" { params: v2::ListMcpServersParams, response: v2::ListMcpServersResponse, @@ -524,6 +529,7 @@ server_notification_definitions! { CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ea70b805b0..dbef55ed15 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -688,6 +688,26 @@ pub struct ListMcpServersResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1467,6 +1487,17 @@ pub struct McpToolCallProgressNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 99d5a7a141..e4a326a2c3 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -26,6 +26,7 @@ codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } +codex-rmcp-client = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65721a698e..0a8445055d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -55,6 +55,9 @@ use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServer; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -115,6 +118,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; @@ -147,6 +151,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::collections::HashSet; @@ -161,6 +166,7 @@ use std::time::Duration; use tokio::select; use tokio::sync::Mutex; use tokio::sync::oneshot; +use toml::Value as TomlValue; use tracing::error; use tracing::info; use tracing::warn; @@ -198,6 +204,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -244,6 +251,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, feedback: CodexFeedback, ) -> Self { Self { @@ -252,6 +260,7 @@ impl CodexMessageProcessor { outgoing, codex_linux_sandbox_exe, config, + cli_overrides, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -261,6 +270,16 @@ impl CodexMessageProcessor { } } + async fn load_latest_config(&self) -> Result { + Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default()) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to reload config: {err}"), + data: None, + }) + } + fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { @@ -369,6 +388,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServerOauthLogin { request_id, params } => { + self.mcp_server_oauth_login(request_id, params).await; + } ClientRequest::McpServersList { request_id, params } => { self.list_mcp_servers(request_id, params).await; } @@ -1916,6 +1938,110 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn mcp_server_oauth_login( + &self, + request_id: RequestId, + params: McpServerOauthLoginParams, + ) { + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if !config.features.enabled(Feature::RmcpClient) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported when [features].rmcp_client is true in config.toml".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let Some(server) = config.mcp_servers.get(&name) else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("No MCP server named '{name}' found."), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported for streamable HTTP servers." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + scopes.as_deref().unwrap_or_default(), + timeout_secs, + ) + .await + { + Ok(handle) => { + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + let response = McpServerOauthLoginResponse { authorization_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to login to MCP server '{name}': {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 90560e9b3c..6a6cf5edb2 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -59,6 +59,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, Arc::clone(&config), + cli_overrides.clone(), feedback, ); let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides); diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index ac617f3d29..954898cea4 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -16,7 +16,9 @@ pub use oauth::WrappedOAuthTokenResponse; pub use oauth::delete_oauth_tokens; pub(crate) use oauth::load_oauth_tokens; pub use oauth::save_oauth_tokens; +pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; +pub use perform_oauth_login::perform_oauth_login_return_url; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index d8ffdd3949..9815a3a22d 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -22,6 +22,11 @@ use crate::save_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +struct OauthHeaders { + http_headers: Option>, + env_http_headers: Option>, +} + struct CallbackServerGuard { server: Arc, } @@ -40,70 +45,52 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], ) -> Result<()> { - let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); - let guard = CallbackServerGuard { - server: Arc::clone(&server), + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + true, + None, + ) + .await? + .finish() + .await +} - let redirect_uri = match server.server_addr() { - tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { - format!("http://{}:{}/callback", addr.ip(), addr.port()) - } - tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { - format!("http://[{}]:{}/callback", addr.ip(), addr.port()) - } - #[cfg(not(target_os = "windows"))] - _ => return Err(anyhow!("unable to determine callback address")), +pub async fn perform_oauth_login_return_url( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + timeout_secs: Option, +) -> Result { + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + let flow = OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + false, + timeout_secs, + ) + .await?; - let (tx, rx) = oneshot::channel(); - spawn_callback_server(server, tx); + let authorization_url = flow.authorization_url(); + let completion = flow.spawn(); - let default_headers = build_default_headers(http_headers, env_http_headers)?; - let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; - - let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; - let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - oauth_state - .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) - .await?; - let auth_url = oauth_state.get_authorization_url().await?; - - println!("Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n"); - - if webbrowser::open(&auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); - } - - let (code, csrf_state) = timeout(Duration::from_secs(300), rx) - .await - .context("timed out waiting for OAuth callback")? - .context("OAuth callback was cancelled")?; - - oauth_state - .handle_callback(&code, &csrf_state) - .await - .context("failed to handle OAuth callback")?; - - let (client_id, credentials_opt) = oauth_state - .get_credentials() - .await - .context("failed to retrieve OAuth credentials")?; - let credentials = - credentials_opt.ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; - - let expires_at = compute_expires_at_millis(&credentials); - let stored = StoredOAuthTokens { - server_name: server_name.to_string(), - url: server_url.to_string(), - client_id, - token_response: WrappedOAuthTokenResponse(credentials), - expires_at, - }; - save_oauth_tokens(server_name, &stored, store_mode)?; - - drop(guard); - Ok(()) + Ok(OauthLoginHandle::new(authorization_url, completion)) } fn spawn_callback_server(server: Arc, tx: oneshot::Sender<(String, String)>) { @@ -160,3 +147,181 @@ fn parse_oauth_callback(path: &str) -> Option { state: state?, }) } + +pub struct OauthLoginHandle { + authorization_url: String, + completion: oneshot::Receiver>, +} + +impl OauthLoginHandle { + fn new(authorization_url: String, completion: oneshot::Receiver>) -> Self { + Self { + authorization_url, + completion, + } + } + + pub fn authorization_url(&self) -> &str { + &self.authorization_url + } + + pub fn into_parts(self) -> (String, oneshot::Receiver>) { + (self.authorization_url, self.completion) + } + + pub async fn wait(self) -> Result<()> { + self.completion + .await + .map_err(|err| anyhow!("OAuth login task was cancelled: {err}"))? + } +} + +struct OauthLoginFlow { + auth_url: String, + oauth_state: OAuthState, + rx: oneshot::Receiver<(String, String)>, + guard: CallbackServerGuard, + server_name: String, + server_url: String, + store_mode: OAuthCredentialsStoreMode, + launch_browser: bool, + timeout: Duration, +} + +impl OauthLoginFlow { + async fn new( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + headers: OauthHeaders, + scopes: &[String], + launch_browser: bool, + timeout_secs: Option, + ) -> Result { + const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300; + + let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); + let guard = CallbackServerGuard { + server: Arc::clone(&server), + }; + + let redirect_uri = match server.server_addr() { + tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://{ip}:{port}/callback") + } + tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://[{ip}]:{port}/callback") + } + #[cfg(not(target_os = "windows"))] + _ => return Err(anyhow!("unable to determine callback address")), + }; + + let (tx, rx) = oneshot::channel(); + spawn_callback_server(server, tx); + + let OauthHeaders { + http_headers, + env_http_headers, + } = headers; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + oauth_state + .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) + .await?; + let auth_url = oauth_state.get_authorization_url().await?; + let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1); + let timeout = Duration::from_secs(timeout_secs as u64); + + Ok(Self { + auth_url, + oauth_state, + rx, + guard, + server_name: server_name.to_string(), + server_url: server_url.to_string(), + store_mode, + launch_browser, + timeout, + }) + } + + fn authorization_url(&self) -> String { + self.auth_url.clone() + } + + async fn finish(mut self) -> Result<()> { + if self.launch_browser { + let server_name = &self.server_name; + let auth_url = &self.auth_url; + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + + if webbrowser::open(auth_url).is_err() { + println!("(Browser launch failed; please copy the URL above manually.)"); + } + } + + let result = async { + let (code, csrf_state) = timeout(self.timeout, &mut self.rx) + .await + .context("timed out waiting for OAuth callback")? + .context("OAuth callback was cancelled")?; + + self.oauth_state + .handle_callback(&code, &csrf_state) + .await + .context("failed to handle OAuth callback")?; + + let (client_id, credentials_opt) = self + .oauth_state + .get_credentials() + .await + .context("failed to retrieve OAuth credentials")?; + let credentials = credentials_opt + .ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; + + let expires_at = compute_expires_at_millis(&credentials); + let stored = StoredOAuthTokens { + server_name: self.server_name.clone(), + url: self.server_url.clone(), + client_id, + token_response: WrappedOAuthTokenResponse(credentials), + expires_at, + }; + save_oauth_tokens(&self.server_name, &stored, self.store_mode)?; + + Ok(()) + } + .await; + + drop(self.guard); + result + } + + fn spawn(self) -> oneshot::Receiver> { + let server_name_for_logging = self.server_name.clone(); + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let result = self.finish().await; + + if let Err(err) = &result { + eprintln!( + "Failed to complete OAuth login for '{server_name_for_logging}': {err:#}" + ); + } + + let _ = tx.send(result); + }); + + rx + } +} From 967d063f4bd50c6c7b8402c504e5c2045f3d2165 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 9 Dec 2025 18:30:16 -0800 Subject: [PATCH 109/159] parse rg | head a search (#7797) --- codex-rs/core/src/parse_command.rs | 187 +++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index f335347042..399513f5ae 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -117,9 +117,6 @@ mod tests { query: None, path: None, }, - ParsedCommand::Unknown { - cmd: "head -n 40".to_string(), - }, ], ); } @@ -143,16 +140,11 @@ mod tests { let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), - query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 200".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), + query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), + path: None, + }], ); } @@ -174,16 +166,11 @@ mod tests { let inner = "rg --files | head -n 50"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 50".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -273,6 +260,19 @@ mod tests { ); } + #[test] + fn supports_head_file_only() { + let inner = "head Cargo.toml"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } + #[test] fn supports_cat_sed_n() { let inner = "cat tui/Cargo.toml | sed -n '1,200p'"; @@ -313,6 +313,19 @@ mod tests { ); } + #[test] + fn supports_tail_file_only() { + let inner = "tail README.md"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "README.md".to_string(), + path: PathBuf::from("README.md"), + }], + ); + } + #[test] fn supports_npm_run_build_is_unknown() { assert_parsed( @@ -391,6 +404,19 @@ mod tests { ); } + #[test] + fn supports_single_string_script_with_cd_and_pipe() { + let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Search { + cmd: "rg -n codex_api codex-rs -S".to_string(), + query: Some("codex_api".to_string()), + path: Some("codex-rs".to_string()), + }], + ); + } + // ---- is_small_formatting_command unit tests ---- #[test] fn small_formatting_always_true_commands() { @@ -408,38 +434,43 @@ mod tests { fn head_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["head"]))); - // Numeric count only -> not considered small formatting by implementation - assert!(!is_small_formatting_command(&shlex_split_safe( - "head -n 40" - ))); + // Numeric count only -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("head -n 40"))); // With explicit file -> not small formatting assert!(!is_small_formatting_command(&shlex_split_safe( "head -n 40 file.txt" ))); - // File only (no count) -> treated as small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"]))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "head", "file.txt" + ]))); } #[test] fn tail_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["tail"]))); - // Numeric with plus offset -> not small formatting - assert!(!is_small_formatting_command(&shlex_split_safe( + // Numeric with plus offset -> formatting + assert!(is_small_formatting_command(&shlex_split_safe( "tail -n +10" ))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n +10 file.txt" ))); - // Numeric count - assert!(!is_small_formatting_command(&shlex_split_safe( - "tail -n 30" - ))); + // Numeric count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30"))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n 30 file.txt" ))); - // File only -> small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"]))); + // Byte count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30"))); + assert!(is_small_formatting_command(&shlex_split_safe( + "tail -c +10" + ))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "tail", "file.txt" + ]))); } #[test] @@ -714,20 +745,15 @@ mod tests { #[test] fn bash_dash_c_pipeline_parsing() { - // Ensure -c is handled similarly to -lc by normalization + // Ensure -c is handled similarly to -lc by shell parsing let inner = "rg --files | head -n 1"; assert_parsed( - &shlex_split_safe(inner), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 1".to_string(), - }, - ], + &vec_str(&["bash", "-c", inner]), + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -1384,13 +1410,50 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { // Treat as formatting when no explicit file operand is present. // Common forms: `head -n 40`, `head -c 100`. // Keep cases like `head -n 40 file`. - tokens.len() < 3 + match tokens { + // `head` + [_] => true, + // `head ` or `head -n50`/`head -c100` + [_, arg] => arg.starts_with('-'), + // `head -n 40` / `head -c 100` (no file operand) + [_, flag, count] + if (flag == "-n" || flag == "-c") + && count.chars().all(|c| c.is_ascii_digit()) => + { + true + } + _ => false, + } } "tail" => { // Treat as formatting when no explicit file operand is present. - // Common forms: `tail -n +10`, `tail -n 30`. + // Common forms: `tail -n +10`, `tail -n 30`, `tail -c 100`. // Keep cases like `tail -n 30 file`. - tokens.len() < 3 + match tokens { + // `tail` + [_] => true, + // `tail ` or `tail -n30`/`tail -n+10` + [_, arg] => arg.starts_with('-'), + // `tail -n 30` / `tail -n +10` (no file operand) + [_, flag, count] + if flag == "-n" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + // `tail -c 100` / `tail -c +10` (no file operand) + [_, flag, count] + if flag == "-c" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + _ => false, + } } "sed" => { // Keep `sed -n file` (treated as a file read elsewhere); @@ -1543,6 +1606,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } @@ -1587,6 +1660,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } From fc4249313b723102c82e59272efcc4ad27609ae3 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 9 Dec 2025 19:00:33 -0800 Subject: [PATCH 110/159] Elevated Sandbox 1 (#7788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updating helpers, refactoring some functions that will be used in the elevated sandbox - better logging - better and faster handling of ACL checks/writes - No functional change—legacy restricted-token sandbox remains the only path. --- codex-rs/Cargo.lock | 1 + codex-rs/windows-sandbox-rs/Cargo.toml | 1 + codex-rs/windows-sandbox-rs/src/acl.rs | 346 ++++++++++++++++++--- codex-rs/windows-sandbox-rs/src/audit.rs | 180 ++--------- codex-rs/windows-sandbox-rs/src/env.rs | 34 +- codex-rs/windows-sandbox-rs/src/logging.rs | 24 +- codex-rs/windows-sandbox-rs/src/process.rs | 42 ++- codex-rs/windows-sandbox-rs/src/token.rs | 64 +++- codex-rs/windows-sandbox-rs/src/winutil.rs | 20 ++ 9 files changed, 480 insertions(+), 232 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a3cd95dfa..bca96ff631 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1727,6 +1727,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "codex-protocol", "dirs-next", "dunce", diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 29306371b2..1b936f05ca 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" +chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index f2e1e09480..34d523d1f5 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -1,4 +1,4 @@ -use crate::winutil::to_wide; +use crate::winutil::to_wide; use anyhow::anyhow; use anyhow::Result; use std::ffi::c_void; @@ -9,6 +9,7 @@ use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::Authorization::GetEffectiveRightsFromAclW; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::GetSecurityInfo; use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; @@ -21,28 +22,148 @@ use windows_sys::Win32::Security::Authorization::TRUSTEE_W; use windows_sys::Win32::Security::EqualSid; use windows_sys::Win32::Security::GetAce; use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::MapGenericMask; use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; use windows_sys::Win32::Security::ACE_HEADER; use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Security::GENERIC_MAPPING; use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; const SE_KERNEL_OBJECT: u32 = 6; const INHERIT_ONLY_ACE: u8 = 0x08; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; const DENY_ACCESS: i32 = 3; +/// Fetch DACL via handle-based query; caller must LocalFree the returned SD. +pub unsafe fn fetch_dacl_handle(path: &Path) -> Result<(*mut ACL, *mut c_void)> { + let wpath = to_wide(path); + let h = CreateFileW( + wpath.as_ptr(), + READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null_mut(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + 0, + ); + if h == INVALID_HANDLE_VALUE { + return Err(anyhow!("CreateFileW failed for {}", path.display())); + } + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetSecurityInfo( + h, + 1, // SE_FILE_OBJECT + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + CloseHandle(h); + if code != ERROR_SUCCESS { + return Err(anyhow!( + "GetSecurityInfo failed for {}: {}", + path.display(), + code + )); + } + Ok((p_dacl, p_sd)) +} + +/// Fast mask-based check: does any ACE for provided SIDs grant at least one desired bit? Skips inherit-only. +pub unsafe fn dacl_quick_mask_allows( + p_dacl: *mut ACL, + psids: &[*mut c_void], + desired_mask: u32, +) -> bool { + if p_dacl.is_null() { + return false; + } + let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); + let ok = GetAclInformation( + p_dacl as *const ACL, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ); + if ok == 0 { + return false; + } + let mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, + }; + for i in 0..(info.AceCount as usize) { + let mut p_ace: *mut c_void = std::ptr::null_mut(); + if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { + continue; + } + let hdr = &*(p_ace as *const ACE_HEADER); + if hdr.AceType != 0 { + continue; // not ACCESS_ALLOWED + } + if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + let base = p_ace as usize; + let sid_ptr = + (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; + let mut matched = false; + for sid in psids { + if EqualSid(sid_ptr, *sid) != 0 { + matched = true; + break; + } + } + if !matched { + continue; + } + let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); + let mut mask = ace.Mask; + MapGenericMask(&mut mask, &mapping); + if (mask & desired_mask) != 0 { + return true; + } + } + false +} + +/// Path-based wrapper around the quick mask check (single DACL fetch). +pub fn path_quick_mask_allows( + path: &Path, + psids: &[*mut c_void], + desired_mask: u32, +) -> Result { + unsafe { + let (p_dacl, sd) = fetch_dacl_handle(path)?; + let has = dacl_quick_mask_allows(p_dacl, psids, desired_mask); + if !sd.is_null() { + LocalFree(sd as HLOCAL); + } + Ok(has) + } +} + pub unsafe fn dacl_has_write_allow_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> bool { if p_dacl.is_null() { return false; @@ -131,6 +252,44 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) - // This accounts for deny ACEs and ordering; falls back to a conservative per-ACE scan if the API fails. #[allow(dead_code)] pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut access: u32 = 0; + let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” write when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + let write_bits = FILE_GENERIC_WRITE + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES; + return (mapped_access & write_bits) != 0; + } + // Fallback: simple allow ACE scan (already ignores inherit-only) + dacl_has_write_allow_for_sid(p_dacl, psid) +} + +#[allow(dead_code)] +pub unsafe fn dacl_effective_allows_mask( + p_dacl: *mut ACL, + psid: *mut c_void, + desired_mask: u32, +) -> bool { if p_dacl.is_null() { return false; } @@ -148,18 +307,100 @@ pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) - }; let mut access: u32 = 0; let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); - if ok != 0 { - // Check for generic or specific write bits - let write_bits = FILE_GENERIC_WRITE - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; - return (access & write_bits) != 0; + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + return (mapped_access & desired_mask) == desired_mask; } - // Fallback: simple allow ACE scan (already ignores inherit-only) - dacl_has_write_allow_for_sid(p_dacl, psid) + // Fallbacks on error: if write bits are requested, reuse the write helper; otherwise fail closed. + if (desired_mask & FILE_GENERIC_WRITE) != 0 { + return dacl_effective_allows_write(p_dacl, psid); + } + false } + +#[allow(dead_code)] +const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + +/// Ensure all provided SIDs have a write-capable allow ACE on the path. +/// Returns true if any ACE was added. +#[allow(dead_code)] +pub unsafe fn ensure_allow_write_aces(path: &Path, sids: &[*mut c_void]) -> Result { + let (p_dacl, p_sd) = fetch_dacl_handle(path)?; + let mut entries: Vec = Vec::new(); + for sid in sids { + if dacl_quick_mask_allows(p_dacl, &[*sid], WRITE_ALLOW_MASK) { + continue; + } + entries.push(EXPLICIT_ACCESS_W { + grfAccessPermissions: WRITE_ALLOW_MASK, + grfAccessMode: 2, // SET_ACCESS + grfInheritance: CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: *sid as *mut u16, + }, + }); + } + let mut added = false; + if !entries.is_empty() { + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW( + entries.len() as u32, + entries.as_ptr(), + p_dacl, + &mut p_new_dacl, + ); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = true; + } else { + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetNamedSecurityInfoW failed: {}", code3)); + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + } else { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetEntriesInAclW failed: {}", code2)); + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + Ok(added) +} + +/// Adds an allow ACE granting read/write/execute to the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); @@ -176,39 +417,44 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { if code != ERROR_SUCCESS { return Err(anyhow!("GetNamedSecurityInfoW failed: {}", code)); } + // Already has write? Skip costly DACL rewrite. + if dacl_has_write_allow_for_sid(p_dacl, psid) { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Ok(false); + } let mut added = false; - if !dacl_has_write_allow_for_sid(p_dacl, psid) { - let trustee = TRUSTEE_W { - pMultipleTrustee: std::ptr::null_mut(), - MultipleTrusteeOperation: 0, - TrusteeForm: TRUSTEE_IS_SID, - TrusteeType: TRUSTEE_IS_UNKNOWN, - ptstrName: psid as *mut u16, - }; - let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); - explicit.grfAccessPermissions = - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; - explicit.grfAccessMode = 2; // SET_ACCESS - explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; - explicit.Trustee = trustee; - let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); - let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); - if code2 == ERROR_SUCCESS { - let code3 = SetNamedSecurityInfoW( - to_wide(path).as_ptr() as *mut u16, - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - p_new_dacl, - std::ptr::null_mut(), - ); - if code3 == ERROR_SUCCESS { - added = true; - } - if !p_new_dacl.is_null() { - LocalFree(p_new_dacl as HLOCAL); - } + // Always ensure write is present: if an allow ACE exists without write, add one with write+RX. + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + explicit.grfAccessMode = 2; // SET_ACCESS + explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = !dacl_has_write_allow_for_sid(p_dacl, psid); + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); } } if !p_sd.is_null() { @@ -217,6 +463,10 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { Ok(added) } +/// Adds a deny ACE to prevent write/append/delete for the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result { let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); @@ -330,6 +580,10 @@ pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) { } } +/// Grants RX to the null device for the given SID to support stdout/stderr redirection. +/// +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. pub unsafe fn allow_null_device(psid: *mut c_void) { let desired = 0x00020000 | 0x00040000; // READ_CONTROL | WRITE_DAC let h = CreateFileW( diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 6234dbf26a..7e35bf7517 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,12 +1,12 @@ use crate::acl::add_deny_write_ace; +use crate::acl::path_quick_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; -use crate::logging::log_note; +use crate::logging::{debug_log, log_note}; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; use anyhow::anyhow; -use crate::winutil::to_wide; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; @@ -14,38 +14,10 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::ERROR_SUCCESS; -use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; -use windows_sys::Win32::Foundation::LocalFree; -use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; -use windows_sys::Win32::Security::ACE_HEADER; -use windows_sys::Win32::Security::ACL; -use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; -use windows_sys::Win32::Security::AclSizeInformation; -use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; -use windows_sys::Win32::Security::Authorization::GetSecurityInfo; -use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; -use windows_sys::Win32::Security::EqualSid; -use windows_sys::Win32::Security::GetAce; -use windows_sys::Win32::Security::GetAclInformation; -use windows_sys::Win32::Security::MapGenericMask; -use windows_sys::Win32::Security::GENERIC_MAPPING; -use windows_sys::Win32::Storage::FileSystem::CreateFileW; -use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; -use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; // Preflight scan limits const MAX_ITEMS_PER_DIR: i32 = 1000; @@ -109,79 +81,10 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap } unsafe fn path_has_world_write_allow(path: &Path) -> Result { - // Prefer handle-based query (often faster than name-based), fallback to name-based on error - let mut p_sd: *mut c_void = std::ptr::null_mut(); - let mut p_dacl: *mut ACL = std::ptr::null_mut(); - - let mut try_named = false; - let wpath = to_wide(path); - let h = CreateFileW( - wpath.as_ptr(), - 0x00020000, // READ_CONTROL - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - 0, - ); - if h == INVALID_HANDLE_VALUE { - try_named = true; - } else { - let code = GetSecurityInfo( - h, - 1, // SE_FILE_OBJECT - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - CloseHandle(h); - if code != ERROR_SUCCESS { - try_named = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - p_sd = std::ptr::null_mut(); - p_dacl = std::ptr::null_mut(); - } - } - } - - if try_named { - let code = GetNamedSecurityInfoW( - wpath.as_ptr(), - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - if code != ERROR_SUCCESS { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - } - let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; - // Very fast mask-based check for world-writable grants (includes GENERIC_*). - if !dacl_quick_world_write_mask_allows(p_dacl, psid_world) { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - // Quick detector flagged a write grant for Everyone: treat as writable. - let has = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - Ok(has) + let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; + path_quick_mask_allows(path, &[psid_world], write_mask) } pub fn audit_everyone_writable( @@ -193,6 +96,21 @@ pub fn audit_everyone_writable( let mut flagged: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut checked = 0usize; + let check_world_writable = |path: &Path| -> bool { + match unsafe { path_has_world_write_allow(path) } { + Ok(has) => has, + Err(err) => { + debug_log( + &format!( + "AUDIT: treating unreadable ACL as not world-writable: {} ({err})", + path.display() + ), + logs_base_dir, + ); + false + } + } + }; // Fast path: check CWD immediate children first so workspace issues are caught early. if let Ok(read) = std::fs::read_dir(cwd) { for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { @@ -210,7 +128,7 @@ pub fn audit_everyone_writable( } let p = ent.path(); checked += 1; - let has = unsafe { path_has_world_write_allow(&p)? }; + let has = check_world_writable(&p); if has { let key = normalize_path_key(&p); if seen.insert(key) { @@ -228,7 +146,7 @@ pub fn audit_everyone_writable( break; } checked += 1; - let has_root = unsafe { path_has_world_write_allow(&root)? }; + let has_root = check_world_writable(&root); if has_root { let key = normalize_path_key(&root); if seen.insert(key) { @@ -260,7 +178,7 @@ pub fn audit_everyone_writable( } if ft.is_dir() { checked += 1; - let has_child = unsafe { path_has_world_write_allow(&p)? }; + let has_child = check_world_writable(&p); if has_child { let key = normalize_path_key(&p); if seen.insert(key) { @@ -384,57 +302,3 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } -// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for -// Everyone that grants write after generic bits are expanded? Skips inherit-only -// ACEs (do not apply to the current object). -unsafe fn dacl_quick_world_write_mask_allows(p_dacl: *mut ACL, psid_world: *mut c_void) -> bool { - if p_dacl.is_null() { - return false; - } - const INHERIT_ONLY_ACE: u8 = 0x08; - let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); - let ok = GetAclInformation( - p_dacl as *const ACL, - &mut info as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - AclSizeInformation, - ); - if ok == 0 { - return false; - } - let mapping = GENERIC_MAPPING { - GenericRead: FILE_GENERIC_READ, - GenericWrite: FILE_GENERIC_WRITE, - GenericExecute: FILE_GENERIC_EXECUTE, - GenericAll: FILE_ALL_ACCESS, - }; - for i in 0..(info.AceCount as usize) { - let mut p_ace: *mut c_void = std::ptr::null_mut(); - if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { - continue; - } - let hdr = &*(p_ace as *const ACE_HEADER); - if hdr.AceType != 0 { - // ACCESS_ALLOWED_ACE_TYPE - continue; - } - if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { - continue; - } - let base = p_ace as usize; - let sid_ptr = - (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; // skip header + mask - if EqualSid(sid_ptr, psid_world) == 0 { - continue; - } - let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); - let mut mask = ace.Mask; - // Expand generic bits to concrete file rights before checking for write. - MapGenericMask(&mut mask, &mapping); - let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; - if (mask & write_mask) != 0 { - return true; - } - } - false -} diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs index a8a3cda71d..65950a0d59 100644 --- a/codex-rs/windows-sandbox-rs/src/env.rs +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -1,11 +1,10 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; +use dirs_next::home_dir; use std::collections::HashMap; use std::env; -use std::fs::File; -use std::fs::{self}; +use std::fs::{self, File}; use std::io::Write; -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn normalize_null_device_env(env_map: &mut HashMap) { let keys: Vec = env_map.keys().cloned().collect(); @@ -29,6 +28,21 @@ pub fn ensure_non_interactive_pager(env_map: &mut HashMap) { env_map.entry("LESS".into()).or_insert_with(|| "".into()); } +// Keep PATH and PATHEXT stable for callers that rely on inheriting the parent process env. +#[allow(dead_code)] +pub fn inherit_path_env(env_map: &mut HashMap) { + if !env_map.contains_key("PATH") { + if let Ok(path) = env::var("PATH") { + env_map.insert("PATH".into(), path); + } + } + if !env_map.contains_key("PATHEXT") { + if let Ok(pathext) = env::var("PATHEXT") { + env_map.insert("PATHEXT".into(), pathext); + } + } +} + fn prepend_path(env_map: &mut HashMap, prefix: &str) { let existing = env_map .get("PATH") @@ -64,7 +78,7 @@ fn reorder_pathext_for_stubs(env_map: &mut HashMap) { .map(|s| s.to_string()) .collect(); let exts_norm: Vec = exts.iter().map(|e| e.to_ascii_uppercase()).collect(); - let want = [".BAT", ".CMD"]; // move to front if present + let want = [".BAT", ".CMD"]; let mut front: Vec = Vec::new(); for w in want { if let Some(idx) = exts_norm.iter().position(|e| e == w) { @@ -90,7 +104,7 @@ fn ensure_denybin(tools: &[&str], denybin_dir: Option<&Path>) -> Result let base = match denybin_dir { Some(p) => p.to_path_buf(), None => { - let home = dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; + let home = home_dir().ok_or_else(|| anyhow!("no home dir"))?; home.join(".sbx-denybin") } }; @@ -146,16 +160,12 @@ pub fn apply_no_network_to_env(env_map: &mut HashMap) -> Result< .entry("GIT_ALLOW_PROTOCOLS".into()) .or_insert_with(|| "".into()); - // Block interactive network tools that bypass HTTP(S) proxy settings, but - // allow curl/wget to run so commands like `curl --version` still succeed. - // Network access is disabled via proxy envs above. let base = ensure_denybin(&["ssh", "scp"], None)?; - // Clean up any stale stubs from previous runs so real curl/wget can run. for tool in ["curl", "wget"] { for ext in [".bat", ".cmd"] { let p = base.join(format!("{}{}", tool, ext)); if p.exists() { - let _ = std::fs::remove_file(&p); + let _ = fs::remove_file(&p); } } } diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 9887ce81d2..2e4de1d29a 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -2,9 +2,20 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; +use std::sync::OnceLock; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; +pub const LOG_FILE_NAME: &str = "sandbox.log"; + +fn exe_label() -> &'static str { + static LABEL: OnceLock = OnceLock::new(); + LABEL.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "proc".to_string()) + }) +} fn preview(command: &[String]) -> String { let joined = command.join(" "); @@ -35,17 +46,17 @@ fn append_line(line: &str, base_dir: Option<&Path>) { pub fn log_start(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("START: {p}"), base_dir); + log_note(&format!("START: {p}"), base_dir); } pub fn log_success(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("SUCCESS: {p}"), base_dir); + log_note(&format!("SUCCESS: {p}"), base_dir); } pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("FAILURE: {p} ({detail})"), base_dir); + log_note(&format!("FAILURE: {p} ({detail})"), base_dir); } // Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. @@ -56,7 +67,8 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox_commands.rust.log +// Unconditional note logging to sandbox.log pub fn log_note(msg: &str, base_dir: Option<&Path>) { - append_line(msg, base_dir); + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); } diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 095dcf8b98..9f73e5d0d4 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -79,6 +79,7 @@ fn quote_arg(a: &str) -> String { out.push('"'); out } +#[allow(dead_code)] unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { let h = GetStdHandle(kind); @@ -96,12 +97,16 @@ unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { Ok(()) } +/// # Safety +/// Caller must provide a valid primary token handle (`h_token`) with appropriate access, +/// and the `argv`, `cwd`, and `env_map` must remain valid for the duration of the call. pub unsafe fn create_process_as_user( h_token: HANDLE, argv: &[String], cwd: &Path, env_map: &HashMap, logs_base_dir: Option<&Path>, + stdio: Option<(HANDLE, HANDLE, HANDLE)>, ) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { let cmdline_str = argv .iter() @@ -117,19 +122,41 @@ pub unsafe fn create_process_as_user( // Point explicitly at the interactive desktop. let desktop = to_wide("Winsta0\\Default"); si.lpDesktop = desktop.as_ptr() as *mut u16; - ensure_inheritable_stdio(&mut si)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + // Ensure handles are inheritable when custom stdio is supplied. + let inherit_handles = match stdio { + Some((stdin_h, stdout_h, stderr_h)) => { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = stdin_h; + si.hStdOutput = stdout_h; + si.hStdError = stderr_h; + for h in [stdin_h, stdout_h, stderr_h] { + if SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(anyhow!( + "SetHandleInformation failed for stdio handle: {}", + GetLastError() + )); + } + } + true + } + None => { + ensure_inheritable_stdio(&mut si)?; + true + } + }; + let ok = CreateProcessAsUserW( h_token, std::ptr::null(), cmdline.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), - 1, + inherit_handles as i32, CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *mut c_void, to_wide(cwd).as_ptr(), - &si, + &mut si, &mut pi, ); if ok == 0 { @@ -149,6 +176,9 @@ pub unsafe fn create_process_as_user( Ok((pi, si)) } +/// # Safety +/// Caller must provide valid process information handles. +#[allow(dead_code)] pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result { let res = WaitForSingleObject(pi.hProcess, INFINITE); if res != 0 { @@ -161,6 +191,9 @@ pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result Ok(code as i32) } +/// # Safety +/// Caller must close the returned job handle. +#[allow(dead_code)] pub unsafe fn create_job_kill_on_close() -> Result { let h = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); if h == 0 { @@ -183,6 +216,9 @@ pub unsafe fn create_job_kill_on_close() -> Result { Ok(h) } +/// # Safety +/// Caller must pass valid handles for a job object and a process. +#[allow(dead_code)] pub unsafe fn assign_to_job(h_job: HANDLE, h_process: HANDLE) -> Result<()> { if AssignProcessToJobObject(h_job, h_process) == 0 { return Err(anyhow!( diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 60eae9377f..7e565bc67a 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -24,6 +24,7 @@ use windows_sys::Win32::Security::TOKEN_DUPLICATE; use windows_sys::Win32::Security::TOKEN_PRIVILEGES; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; const LUA_TOKEN: u32 = 0x04; @@ -52,6 +53,8 @@ pub unsafe fn world_sid() -> Result> { Ok(buf) } +/// # Safety +/// Caller is responsible for freeing the returned SID with `LocalFree`. pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { #[link(name = "advapi32")] extern "system" { @@ -66,6 +69,9 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { } } +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn get_current_token_for_restriction() -> Result { let desired = TOKEN_DUPLICATE | TOKEN_QUERY @@ -197,13 +203,55 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { Ok(()) } -// removed unused create_write_restricted_token_strict +/// # Safety +/// Opens the current process token and adjusts privileges; caller should ensure this is needed in the current context. +#[allow(dead_code)] +pub unsafe fn enable_privilege_on_current(name: &str) -> Result<()> { + let mut h: HANDLE = 0; + let ok = OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut h, + ); + if ok == 0 { + return Err(anyhow!("OpenProcessToken failed: {}", GetLastError())); + } + let res = enable_single_privilege(h, name); + CloseHandle(h); + res +} +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn create_workspace_write_token_with_cap( psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let res = create_workspace_write_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] +pub unsafe fn create_readonly_token_with_cap( + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let base = get_current_token_for_restriction()?; + let res = create_readonly_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_workspace_write_token_with_cap_from( + base_token: HANDLE, + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -218,7 +266,7 @@ pub unsafe fn create_workspace_write_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), @@ -235,11 +283,13 @@ pub unsafe fn create_workspace_write_token_with_cap( Ok((new_token, psid_capability)) } -pub unsafe fn create_readonly_token_with_cap( +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_readonly_token_with_cap_from( + base_token: HANDLE, psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { - let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -254,7 +304,7 @@ pub unsafe fn create_readonly_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs index 5e74ce072e..a819561261 100644 --- a/codex-rs/windows-sandbox-rs/src/winutil.rs +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -6,6 +6,7 @@ use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_ALLOCATE_BUFFER; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_FROM_SYSTEM; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_IGNORE_INSERTS; +use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; pub fn to_wide>(s: S) -> Vec { let mut v: Vec = s.as_ref().encode_wide().collect(); @@ -41,3 +42,22 @@ pub fn format_last_error(err: i32) -> String { s } } + +#[allow(dead_code)] +pub fn string_from_sid_bytes(sid: &[u8]) -> Result { + unsafe { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let ok = ConvertSidToStringSidW(sid.as_ptr() as *mut std::ffi::c_void, &mut str_ptr); + if ok == 0 || str_ptr.is_null() { + return Err(format!("ConvertSidToStringSidW failed: {}", std::io::Error::last_os_error())); + } + let mut len = 0; + while *str_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(str_ptr, len); + let out = String::from_utf16_lossy(slice); + let _ = LocalFree(str_ptr as HLOCAL); + Ok(out) + } +} From 42e081739877ba00528b99090cb63200efc334ce Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 19:31:46 -0800 Subject: [PATCH 111/159] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7757) Reverts openai/codex#7753 Updated the tag ref matching at https://github.com/openai/openai/pull/594858 so that release with tag change can be picked up correctly. --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 0000000000..17a4fbf999 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a..b90f0027fa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From f11520f5f1250f971917619dd1787fb7dc5533fc Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:19:37 -0800 Subject: [PATCH 112/159] Revert "feat: windows codesign with Azure trusted signing" (#7804) Reverts openai/codex#7757 --- .github/actions/windows-code-sign/action.yml | 54 -------------------- .github/workflows/rust-release.yml | 12 ----- 2 files changed, 66 deletions(-) delete mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml deleted file mode 100644 index 17a4fbf999..0000000000 --- a/.github/actions/windows-code-sign/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: windows-code-sign -description: Sign Windows binaries with Azure Trusted Signing. -inputs: - target: - description: Target triple for the artifacts to sign. - required: true - client-id: - description: Azure Trusted Signing client ID. - required: true - tenant-id: - description: Azure tenant ID for Trusted Signing. - required: true - subscription-id: - description: Azure subscription ID for Trusted Signing. - required: true - endpoint: - description: Azure Trusted Signing endpoint. - required: true - account-name: - description: Azure Trusted Signing account name. - required: true - certificate-profile-name: - description: Certificate profile name for signing. - required: true - -runs: - using: composite - steps: - - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ inputs.client-id }} - tenant-id: ${{ inputs.tenant-id }} - subscription-id: ${{ inputs.subscription-id }} - - - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@v0 - with: - endpoint: ${{ inputs.endpoint }} - trusted-signing-account-name: ${{ inputs.account-name }} - certificate-profile-name: ${{ inputs.certificate-profile-name }} - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b90f0027fa..c3e9eeef9a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,18 +110,6 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ contains(matrix.target, 'windows') }} - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From ab9ddcd50bd8a1d6eeb1c721313c889736c2f844 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:42:00 -0800 Subject: [PATCH 113/159] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7806) Reverts openai/codex#7804 --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 0000000000..17a4fbf999 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a..b90f0027fa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From 6fa24d65f5a2d9b3880d7a0e2c5100ba8a55d498 Mon Sep 17 00:00:00 2001 From: Gav Verma Date: Tue, 9 Dec 2025 21:17:57 -0800 Subject: [PATCH 114/159] Express rate limit warning as % remaining (#7795) image Earlier, the warning was expressed as consumed% whereas status was expressed as remaining%. This change brings the two into sync to minimize confusion and improve visual consistency. --- codex-rs/tui/src/chatwidget.rs | 6 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a0b42ddbe4..1d5ad24a03 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -199,8 +199,9 @@ impl RateLimitWarningState { let limit_label = secondary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } @@ -217,8 +218,9 @@ impl RateLimitWarningState { let limit_label = primary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0135abff73..5cc5321f37 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -515,16 +515,16 @@ fn rate_limit_warnings_emit_thresholds() { warnings, vec![ String::from( - "Heads up, you've used over 75% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 75% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", ), String::from( - "Heads up, you've used over 95% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 95% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", ), ], "expected one warning per limit for the highest crossed threshold" @@ -540,7 +540,7 @@ fn test_rate_limit_warnings_monthly() { assert_eq!( warnings, vec![String::from( - "Heads up, you've used over 75% of your monthly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", ),], "expected one warning per limit for the highest crossed threshold" ); From d1c5db579674306c136e0f3e1f751e3510fe553b Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 22:14:14 -0800 Subject: [PATCH 115/159] chore: disable trusted signing pkg cache hit (#7807) --- .github/actions/windows-code-sign/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 17a4fbf999..2be64efc98 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -49,6 +49,7 @@ runs: exclude-azure-powershell-credential: true exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true + cache-dependencies: false files: | ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe From 0ad54982ae9423965e83d78caff495d3c30247ad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 10:30:38 +0000 Subject: [PATCH 116/159] chore: rework unified exec events (#7775) --- .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 11 + codex-rs/app-server-test-client/src/main.rs | 4 + .../app-server/src/bespoke_event_handling.rs | 15 + codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/tools/events.rs | 3 +- .../core/src/tools/handlers/unified_exec.rs | 31 +- .../core/src/unified_exec/async_watcher.rs | 180 ++++++++++ codex-rs/core/src/unified_exec/mod.rs | 44 ++- codex-rs/core/src/unified_exec/session.rs | 43 ++- .../core/src/unified_exec/session_manager.rs | 163 ++++----- codex-rs/core/tests/suite/unified_exec.rs | 336 ++++++++++++++++-- .../src/event_processor_with_human_output.rs | 1 + .../src/event_processor_with_jsonl_output.rs | 45 ++- .../tests/event_processor_with_json_output.rs | 89 +++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 14 + codex-rs/tui/src/chatwidget.rs | 15 +- codex-rs/tui/src/chatwidget/tests.rs | 43 +++ codex-rs/utils/pty/src/lib.rs | 10 +- 20 files changed, 876 insertions(+), 174 deletions(-) create mode 100644 codex-rs/core/src/unified_exec/async_watcher.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c62acc8832..bd9f6ddedf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -527,6 +527,7 @@ server_notification_definitions! { ItemCompleted => "item/completed" (v2::ItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dbef55ed15..db987e27df 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1457,6 +1457,17 @@ pub struct ReasoningTextDeltaNotification { pub content_index: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 92255cecd3..924740896e 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -553,6 +553,10 @@ impl CodexClient { print!("{}", delta.delta); std::io::stdout().flush().ok(); } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } ServerNotification::ItemStarted(payload) => { println!("\n< item started: {:?}", payload.item); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2fda7bcf58..8956aedd13 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -573,6 +574,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::TerminalInteraction(terminal_event) => { + let item_id = terminal_event.call_id.clone(); + + let notification = TerminalInteractionNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }; + outgoing + .send_server_notification(ServerNotification::TerminalInteraction(notification)) + .await; + } EventMsg::ExecCommandEnd(exec_command_end_event) => { let ExecCommandEndEvent { call_id, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f9336..fc6e4b9afd 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 93bce60489..cdfc575cd9 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -134,7 +134,6 @@ impl ToolEmitter { command: &[String], cwd: PathBuf, source: ExecCommandSource, - interaction_input: Option, process_id: Option, ) -> Self { let parsed_cmd = parse_command(command); @@ -142,7 +141,7 @@ impl ToolEmitter { command: command.to_vec(), cwd, source, - interaction_input, + interaction_input: None, // TODO(jif) drop this field in the protocol. parsed_cmd, process_id, } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c7230e54d7..abaaf4a7ab 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,9 +1,8 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::ExecOutputStream; +use crate::protocol::TerminalInteractionEvent; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -189,7 +188,6 @@ impl ToolHandler for UnifiedExecHandler { &command, cwd.clone(), ExecCommandSource::UnifiedExecStartup, - None, Some(process_id.clone()), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -218,7 +216,7 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse write_stdin arguments: {err:?}" )) })?; - manager + let response = manager .write_stdin(WriteStdinRequest { process_id: &args.session_id.to_string(), input: &args.chars, @@ -228,7 +226,18 @@ impl ToolHandler for UnifiedExecHandler { .await .map_err(|err| { FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) - })? + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + response } other => { return Err(FunctionCallError::RespondToModel(format!( @@ -237,18 +246,6 @@ impl ToolHandler for UnifiedExecHandler { } }; - // Emit a delta event with the chunk of output we just produced, if any. - if !response.output.is_empty() { - let delta = ExecCommandOutputDeltaEvent { - call_id: response.event_call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: response.output.as_bytes().to_vec(), - }; - session - .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) - .await; - } - let content = format_response(&response); Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs new file mode 100644 index 0000000000..7412d29720 --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecCommandSource; +use crate::protocol::ExecOutputStream; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; + +use super::CommandTranscript; +use super::UnifiedExecContext; +use super::session::UnifiedExecSession; + +/// Spawn a background task that continuously reads from the PTY, appends to the +/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 +/// boundaries. +pub(crate) fn start_streaming_output( + session: &UnifiedExecSession, + context: &UnifiedExecContext, + transcript: Arc>, +) { + let mut receiver = session.output_receiver(); + let session_ref = Arc::clone(&context.session); + let turn_ref = Arc::clone(&context.turn); + let call_id = context.call_id.clone(); + let cancellation_token = session.cancellation_token(); + + tokio::spawn(async move { + let mut pending: Vec = Vec::new(); + loop { + tokio::select! { + _ = cancellation_token.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + }; + } + }); +} + +/// Spawn a background watcher that waits for the PTY to exit and then emits a +/// single ExecCommandEnd event with the aggregated transcript. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_exit_watcher( + session: Arc, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: String, + transcript: Arc>, + started_at: Instant, +) { + let exit_token = session.cancellation_token(); + + tokio::spawn(async move { + exit_token.cancelled().await; + + let exit_code = session.exit_code().unwrap_or(-1); + let duration = Instant::now().saturating_duration_since(started_at); + emit_exec_end_for_unified_exec( + session_ref, + turn_ref, + call_id, + command, + cwd, + Some(process_id), + transcript, + String::new(), + exit_code, + duration, + ) + .await; + }); +} + +/// Emit an ExecCommandEnd event for a unified exec session, using the transcript +/// as the primary source of aggregated_output and falling back to the provided +/// text when the transcript is empty. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_exec_end_for_unified_exec( + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: Option, + transcript: Arc>, + fallback_output: String, + exit_code: i32, + duration: Duration, +) { + let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await; + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None); + let emitter = ToolEmitter::unified_exec( + &command, + cwd, + ExecCommandSource::UnifiedExecStartup, + process_id, + ); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; +} + +fn split_valid_utf8_prefix(buffer: &mut Vec) -> Option> { + if buffer.is_empty() { + return None; + } + + let len = buffer.len(); + let mut split = len; + while split > 0 { + if std::str::from_utf8(&buffer[..split]).is_ok() { + let prefix = buffer[..split].to_vec(); + buffer.drain(..split); + return Some(prefix); + } + + if len - split > 4 { + break; + } + split -= 1; + } + + // If no valid UTF-8 prefix was found, emit the first byte so the stream + // keeps making progress and the transcript reflects all bytes. + let byte = buffer.drain(..1).collect(); + Some(byte) +} + +async fn resolve_aggregated_output( + transcript: &Arc>, + fallback: String, +) -> String { + let guard = transcript.lock().await; + if guard.data.is_empty() { + return fallback; + } + + String::from_utf8_lossy(&guard.data).to_string() +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 02a0f9ead7..0d86b69fda 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -34,6 +34,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +mod async_watcher; mod errors; mod session; mod session_manager; @@ -51,6 +52,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64; // Send a warning message to the models when it reaches this number of sessions. pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60; +#[derive(Debug, Default)] +pub(crate) struct CommandTranscript { + pub data: Vec, +} + +impl CommandTranscript { + pub fn append(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES { + let excess = self + .data + .len() + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + self.data.drain(..excess); + } + } +} + pub(crate) struct UnifiedExecContext { pub session: Arc, pub turn: Arc, @@ -92,18 +111,14 @@ pub(crate) struct UnifiedExecResponse { pub chunk_id: String, pub wall_time: Duration, pub output: String, + /// Raw bytes returned for this unified exec call before any truncation. + pub raw_output: Vec, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, pub session_command: Option>, } -#[derive(Default)] -pub(crate) struct UnifiedExecSessionManager { - session_store: Mutex, -} - -// Required for mutex sharing. #[derive(Default)] pub(crate) struct SessionStore { sessions: HashMap, @@ -115,22 +130,27 @@ impl SessionStore { self.reserved_sessions_id.remove(session_id); self.sessions.remove(session_id) } +} - pub(crate) fn clear(&mut self) { - self.reserved_sessions_id.clear(); - self.sessions.clear(); +pub(crate) struct UnifiedExecSessionManager { + session_store: Mutex, +} + +impl Default for UnifiedExecSessionManager { + fn default() -> Self { + Self { + session_store: Mutex::new(SessionStore::default()), + } } } struct SessionEntry { - session: UnifiedExecSession, + session: Arc, session_ref: Arc, turn_ref: Arc, call_id: String, process_id: String, command: Vec, - cwd: PathBuf, - started_at: tokio::time::Instant, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 02465538ec..51ebbd3569 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -98,19 +98,22 @@ impl UnifiedExecSession { let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); + tokio::select! { + _ = cancellation_token_clone.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancellation_token_clone.cancel(); + break; + } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } - } + }; } }); @@ -136,6 +139,14 @@ impl UnifiedExecSession { } } + pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { + self.session.output_receiver() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } @@ -144,6 +155,12 @@ impl UnifiedExecSession { self.session.exit_code() } + pub(super) fn terminate(&self) { + self.session.terminate(); + self.cancellation_token.cancel(); + self.output_task.abort(); + } + async fn snapshot_output(&self) -> Vec> { let guard = self.output_buffer.lock().await; guard.snapshot() @@ -246,6 +263,6 @@ impl UnifiedExecSession { impl Drop for UnifiedExecSession { fn drop(&mut self) { - self.output_task.abort(); + self.terminate(); } } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index af706b4b23..fa64eb4bb2 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,18 +13,12 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec::ExecToolCallOutput; -use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandSource; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -33,6 +27,7 @@ use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; use super::SessionEntry; @@ -43,6 +38,9 @@ use super::UnifiedExecResponse; use super::UnifiedExecSessionManager; use super::WARNING_UNIFIED_EXEC_SESSIONS; use super::WriteStdinRequest; +use super::async_watcher::emit_exec_end_for_unified_exec; +use super::async_watcher::spawn_exit_watcher; +use super::async_watcher::start_streaming_output; use super::clamp_yield_time; use super::generate_chunk_id; use super::resolve_max_tokens; @@ -135,17 +133,23 @@ impl UnifiedExecSessionManager { .await; let session = match session { - Ok(session) => session, + Ok(session) => Arc::new(session), Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); } }; + let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default())); + start_streaming_output(&session, context, Arc::clone(&transcript)); + let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); let start = Instant::now(); + // For the initial exec_command call, we both stream output to events + // (via start_streaming_output above) and collect a snapshot here for + // the tool response body. let OutputHandles { output_buffer, output_notify, @@ -163,36 +167,44 @@ impl UnifiedExecSessionManager { let text = String::from_utf8_lossy(&collected).to_string(); let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); - let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let has_exited = session.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + // Short‑lived command: emit ExecCommandEnd immediately using the + // same helper as the background watcher, so all end events share + // one implementation. self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, + emit_exec_end_for_unified_exec( + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + request.command.clone(), cwd, + Some(process_id), + Arc::clone(&transcript), output.clone(), exit, wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(process_id), ) .await; session.check_for_sandbox_denial_with_text(&text).await?; } else { - // Only store session if not exited. + // Long‑lived command: persist the session so write_stdin can reuse + // it, and register a background watcher that will emit + // ExecCommandEnd when the PTY eventually exits (even if no further + // tool calls are made). self.store_session( - session, + Arc::clone(&session), context, &request.command, cwd.clone(), start, process_id, + Arc::clone(&transcript), ) .await; @@ -205,6 +217,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id: if has_exited { None } else { @@ -238,6 +251,8 @@ impl UnifiedExecSessionManager { if !request.input.is_empty() { Self::send_input(&writer_tx, request.input.as_bytes()).await?; + // Give the remote process a brief window to react so that we are + // more likely to capture its output in the poll below. tokio::time::sleep(Duration::from_millis(100)).await; } @@ -259,16 +274,20 @@ impl UnifiedExecSessionManager { let original_token_count = approx_token_count(&text); let chunk_id = generate_chunk_id(); + // After polling, refresh_session_state tells us whether the PTY is + // still alive or has exited and been removed from the store; we thread + // that through so the handler can tag TerminalInteraction with an + // appropriate process_id and exit_code. let status = self.refresh_session_state(process_id.as_str()).await; - let (process_id, exit_code, completion_entry, event_call_id) = match status { + let (process_id, exit_code, event_call_id) = match status { SessionStatus::Alive { exit_code, call_id, process_id, - } => (Some(process_id), exit_code, None, call_id), + } => (Some(process_id), exit_code, call_id), SessionStatus::Exited { exit_code, entry } => { let call_id = entry.call_id.clone(); - (None, exit_code, Some(*entry), call_id) + (None, exit_code, call_id) } SessionStatus::Unknown => { return Err(UnifiedExecError::UnknownSessionId { @@ -282,6 +301,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id, exit_code, original_token_count: Some(original_token_count), @@ -292,12 +312,6 @@ impl UnifiedExecSessionManager { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } - if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { - let total_duration = Instant::now().saturating_duration_since(entry.started_at); - Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) - .await; - } - Ok(response) } @@ -371,28 +385,27 @@ impl UnifiedExecSessionManager { #[allow(clippy::too_many_arguments)] async fn store_session( &self, - session: UnifiedExecSession, + session: Arc, context: &UnifiedExecContext, command: &[String], cwd: PathBuf, started_at: Instant, process_id: String, + transcript: Arc>, ) { let entry = SessionEntry { - session, + session: Arc::clone(&session), session_ref: Arc::clone(&context.session), turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), - cwd, - started_at, last_used: started_at, }; let number_sessions = { let mut store = self.session_store.lock().await; Self::prune_sessions_if_needed(&mut store); - store.sessions.insert(process_id, entry); + store.sessions.insert(process_id.clone(), entry); store.sessions.len() }; @@ -405,73 +418,18 @@ impl UnifiedExecSessionManager { ) .await; }; - } - async fn emit_exec_end_from_entry( - entry: SessionEntry, - aggregated_output: String, - exit_code: i32, - duration: Duration, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - entry.session_ref.as_ref(), - entry.turn_ref.as_ref(), - &entry.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - &entry.command, - entry.cwd, - ExecCommandSource::UnifiedExecStartup, - None, - Some(entry.process_id.clone()), - ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; - } - - async fn emit_exec_end_from_context( - context: &UnifiedExecContext, - command: &[String], - cwd: PathBuf, - aggregated_output: String, - exit_code: i32, - duration: Duration, - process_id: Option, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - context.session.as_ref(), - context.turn.as_ref(), - &context.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - command, + spawn_exit_watcher( + Arc::clone(&session), + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + command.to_vec(), cwd, - ExecCommandSource::UnifiedExecStartup, - None, process_id, + transcript, + started_at, ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; } async fn emit_waiting_status( @@ -567,7 +525,7 @@ impl UnifiedExecSessionManager { cancellation_token: &CancellationToken, deadline: Instant, ) -> Vec { - const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25); + const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50); let mut collected: Vec = Vec::with_capacity(4096); let mut exit_signal_received = cancellation_token.is_cancelled(); @@ -634,7 +592,9 @@ impl UnifiedExecSessionManager { .collect(); if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) { - store.remove(&session_id); + if let Some(entry) = store.remove(&session_id) { + entry.session.terminate(); + } return true; } @@ -671,8 +631,17 @@ impl UnifiedExecSessionManager { } pub(crate) async fn terminate_all_sessions(&self) { - let mut sessions = self.session_store.lock().await; - sessions.clear(); + let entries: Vec = { + let mut sessions = self.session_store.lock().await; + let entries: Vec = + sessions.sessions.drain().map(|(_, entry)| entry).collect(); + sessions.reserved_sessions_id.clear(); + entries + }; + + for entry in entries { + entry.session.terminate(); + } } } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6a62e35dfb..5def7aadb2 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -648,16 +648,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { }) .await?; - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + let event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), _ => None, }) .await; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + let text = event.stdout; assert!( text.contains("HELLO-UEXEC"), - "delta chunk missing expected text: {text:?}" + "delta chunk missing expected text: {text:?}", ); wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; @@ -665,7 +665,116 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { +async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec-full-lifecycle"; + let args = json!({ + "cmd": "printf 'HELLO-FULL-LIFECYCLE'", + "yield_time_ms": 50, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "exercise full unified exec lifecycle".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut saw_delta_with_marker = 0; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev), + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("HELLO-FULL-LIFECYCLE") { + saw_delta_with_marker += 1; + } + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => { + assert!( + end_event.is_none(), + "expected a single ExecCommandEnd event for this call id" + ); + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert_eq!(begin_event.call_id, call_id); + assert!( + begin_event.process_id.is_some(), + "begin event should include a process_id for a long-lived session" + ); + + assert_eq!( + saw_delta_with_marker, 0, + "no ExecCommandOutputDelta should be sent for early exit commands" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include process_id emitted by background watcher" + ); + assert!( + end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"), + "aggregated_output should contain the full PTY transcript; got {:?}", + end_event.aggregated_output + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -740,27 +849,210 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { }) .await?; - // Expect a delta event corresponding to the write_stdin call. - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { - let text = String::from_utf8_lossy(&ev.chunk); - if text.contains("WSTDIN-MARK") { - Some(ev.clone()) - } else { - None - } - } - _ => None, - }) - .await; + let mut terminal_interaction = None; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_interaction = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let delta = terminal_interaction.expect("expected TerminalInteraction event"); + assert_eq!(delta.process_id, "1000"); + let expected_stdin = stdin_args + .get("chars") + .and_then(Value::as_str) + .expect("stdin chars"); + assert_eq!(delta.stdin, expected_stdin); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let open_call_id = "uexec-delayed-open"; + let open_args = json!({ + "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "yield_time_ms": 10, + }); + + // Poll stdin three times: first for no output, second after the first marker, + // and a final long poll to capture the second marker. + let first_poll_call_id = "uexec-delayed-poll-1"; + let first_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10, + }); + + let second_poll_call_id = "uexec-delayed-poll-2"; + let second_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 6000, + }); + + let third_poll_call_id = "uexec-delayed-poll-3"; + let third_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + open_call_id, + "exec_command", + &serde_json::to_string(&open_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + first_poll_call_id, + "write_stdin", + &serde_json::to_string(&first_poll_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_poll_call_id, + "write_stdin", + &serde_json::to_string(&second_poll_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_function_call( + third_poll_call_id, + "write_stdin", + &serde_json::to_string(&third_poll_args)?, + ), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-1", "complete"), + ev_completed("resp-5"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "delayed terminal interaction output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut terminal_events = Vec::new(); + let mut delta_text = String::new(); + + // Consume all events for this turn so we can assert on each stage. + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => { + begin_event = Some(ev); + } + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + delta_text.push_str(&String::from_utf8_lossy(&ev.chunk)); + } + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_events.push(ev); + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); assert!( - text.contains("WSTDIN-MARK"), - "stdin delta chunk missing expected text: {text:?}" + begin_event.process_id.is_some(), + "begin event should include process_id for a live session" + ); + + // We expect three terminal interactions matching the three write_stdin calls. + assert_eq!( + terminal_events.len(), + 3, + "expected three terminal interactions; got {terminal_events:?}" + ); + + for event in &terminal_events { + assert_eq!(event.call_id, open_call_id); + assert_eq!(event.process_id, "1000"); + } + assert_eq!( + terminal_events + .iter() + .map(|ev| ev.stdin.as_str()) + .collect::>(), + vec!["", "", ""], + "terminal interactions should reflect the three stdin polls" + ); + + assert!( + delta_text.contains("MARKER1") && delta_text.contains("MARKER2"), + "streamed deltas should contain both markers; got {delta_text:?}" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, open_call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include the process_id" + ); + assert!( + end_event.aggregated_output.contains("MARKER1") + && end_event.aggregated_output.contains("MARKER2"), + "aggregated output should include both markers in order; got {:?}", + end_event.aggregated_output ); - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; Ok(()) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f35..6eec8b71fc 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -566,6 +566,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::WebSearchBegin(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 23dff015eb..03c51662b1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput { struct RunningCommand { command: String, item_id: String, + aggregated_output: String, } #[derive(Debug, Clone)] @@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput { EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), + EventMsg::ExecCommandOutputDelta(ev) => { + self.handle_output_chunk(&ev.call_id, &ev.chunk) + } EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), @@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { + //TODO see how we want to process them + vec![] + } + + fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec { + //TODO see how we want to process them + vec![] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput { RunningCommand { command: command_string.clone(), item_id: item_id.clone(), + aggregated_output: String::new(), }, ); @@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput { } fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) + let Some(RunningCommand { + command, + item_id, + aggregated_output, + }) = self.running_commands.remove(&ev.call_id) else { warn!( call_id = ev.call_id, @@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; + let aggregated_output = if ev.aggregated_output.is_empty() { + aggregated_output + } else { + ev.aggregated_output.clone() + }; let item = ThreadItem { id: item_id, details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output, exit_code: Some(ev.exit_code), status, }), @@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput { items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + if !self.running_commands.is_empty() { + for (_, running) in self.running_commands.drain() { + let item = ThreadItem { + id: running.item_id, + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: running.command, + aggregated_output: running.aggregated_output, + exit_code: None, + status: CommandExecutionStatus::Completed, + }), + }; + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + } + if let Some(error) = self.last_critical_error.take() { items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); } else { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 7b2d3c18ba..2b3673f5a6 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -48,6 +48,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -699,6 +701,93 @@ fn exec_command_end_success_produces_completed_command_item() { ); } +#[test] +fn command_execution_output_delta_updates_item_progress() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo delta".to_string(), + ]; + let cwd = std::env::current_dir().unwrap(); + let parsed_cmd = Vec::new(); + + let begin = event( + "d1", + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ); + let out_begin = ep.collect_thread_events(&begin); + assert_eq!( + out_begin, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })] + ); + + let delta = event( + "d2", + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "delta-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"partial output\n".to_vec(), + }), + ); + let out_delta = ep.collect_thread_events(&delta); + assert_eq!(out_delta, Vec::::new()); + + let end = event( + "d3", + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_millis(3), + formatted_output: String::new(), + }), + ); + let out_end = ep.collect_thread_events(&end); + assert_eq!( + out_end, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + })] + ); +} + #[test] fn exec_command_end_failure_produces_failed_command_item() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index aa895d8dd3..908cba1cc3 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -282,6 +282,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 89b5fd315a..1e40618f01 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,6 +518,9 @@ pub enum EventMsg { /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + /// Terminal interaction for an in-progress command (stdin sent and stdout observed). + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), /// Notification that the agent attached a local image via the view_image tool. @@ -1455,6 +1458,17 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } +#[serde_as] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TerminalInteractionEvent { + /// Identifier for the ExecCommandBegin that produced this chunk. + pub call_id: String, + /// Process id associated with the running command. + pub process_id: String, + /// Stdin sent to the running session. + pub stdin: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d5ad24a03..dd623ed583 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -46,6 +46,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; @@ -825,6 +826,10 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, @@ -1022,11 +1027,7 @@ impl ChatWidget { } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => ( - vec![ev.call_id.clone()], - Vec::new(), - ExecCommandSource::Agent, - ), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); @@ -1043,7 +1044,7 @@ impl ChatWidget { command, parsed, source, - None, + ev.interaction_input.clone(), self.config.animations, ))); } @@ -1789,6 +1790,7 @@ impl ChatWidget { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); @@ -1846,6 +1848,7 @@ impl ChatWidget { self.on_elicitation_request(ev); } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5cc5321f37..fc1d25edfe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1175,6 +1175,49 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + #[test] fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index dbaf4b81f7..cd98107ca0 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -94,10 +94,8 @@ impl ExecCommandSession { pub fn exit_code(&self) -> Option { self.exit_code.lock().ok().and_then(|guard| *guard) } -} -impl Drop for ExecCommandSession { - fn drop(&mut self) { + pub fn terminate(&self) { if let Ok(mut killer_opt) = self.killer.lock() { if let Some(mut killer) = killer_opt.take() { let _ = killer.kill(); @@ -122,6 +120,12 @@ impl Drop for ExecCommandSession { } } +impl Drop for ExecCommandSession { + fn drop(&mut self) { + self.terminate(); + } +} + #[derive(Debug)] pub struct SpawnedPty { pub session: ExecCommandSession, From 463249eff3ddab40ee17b1647dea3f39b23fa000 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 16:35:28 +0000 Subject: [PATCH 117/159] fix: flaky test 2 (#7818) --- codex-rs/core/src/shell_snapshot.rs | 30 ++++-- .../core/src/unified_exec/async_watcher.rs | 101 +++++++++++++----- codex-rs/core/src/unified_exec/session.rs | 30 +++--- codex-rs/core/tests/suite/shell_snapshot.rs | 6 +- codex-rs/core/tests/suite/unified_exec.rs | 15 ++- 5 files changed, 127 insertions(+), 55 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 4df54997b7..2c4c423f5c 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -367,6 +367,10 @@ mod tests { #[tokio::test] async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { use std::process::Stdio; + use tokio::time::Duration as TokioDuration; + use tokio::time::Instant; + use tokio::time::sleep; + let dir = tempdir()?; let shell_path = dir.path().join("hanging-shell.sh"); let pid_path = dir.path().join("pid"); @@ -402,16 +406,22 @@ mod tests { .trim() .parse::()?; - let kill_status = StdCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stderr(Stdio::null()) - .stdout(Stdio::null()) - .status()?; - assert!( - !kill_status.success(), - "timed out snapshot shell should be terminated" - ); + let deadline = Instant::now() + TokioDuration::from_secs(1); + loop { + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !kill_status.success() { + break; + } + if Instant::now() >= deadline { + panic!("timed out snapshot shell is still alive after grace period"); + } + sleep(TokioDuration::from_millis(50)).await; + } Ok(()) } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 7412d29720..19d91dbccf 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use std::pin::Pin; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::Duration; use tokio::time::Instant; +use tokio::time::Sleep; use crate::codex::Session; use crate::codex::TurnContext; @@ -21,6 +23,8 @@ use super::CommandTranscript; use super::UnifiedExecContext; use super::session::UnifiedExecSession; +pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); + /// Spawn a background task that continuously reads from the PTY, appends to the /// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 /// boundaries. @@ -30,39 +34,58 @@ pub(crate) fn start_streaming_output( transcript: Arc>, ) { let mut receiver = session.output_receiver(); + let output_drained = session.output_drained_notify(); + let exit_token = session.cancellation_token(); + let session_ref = Arc::clone(&context.session); let turn_ref = Arc::clone(&context.turn); let call_id = context.call_id.clone(); - let cancellation_token = session.cancellation_token(); tokio::spawn(async move { - let mut pending: Vec = Vec::new(); + use tokio::sync::broadcast::error::RecvError; + + let mut pending = Vec::::new(); + + let mut grace_sleep: Option>> = None; + loop { tokio::select! { - _ = cancellation_token.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - pending.extend_from_slice(&chunk); - while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { - { - let mut guard = transcript.lock().await; - guard.append(&prefix); - } - - let event = ExecCommandOutputDeltaEvent { - call_id: call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: prefix, - }; - session_ref - .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) - .await; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + _ = exit_token.cancelled(), if grace_sleep.is_none() => { + let deadline = Instant::now() + TRAILING_OUTPUT_GRACE; + grace_sleep.replace(Box::pin(tokio::time::sleep_until(deadline))); } - }; + + _ = async { + if let Some(sleep) = grace_sleep.as_mut() { + sleep.as_mut().await; + } + }, if grace_sleep.is_some() => { + output_drained.notify_one(); + break; + } + + received = receiver.recv() => { + let chunk = match received { + Ok(chunk) => chunk, + Err(RecvError::Lagged(_)) => { + continue; + }, + Err(RecvError::Closed) => { + output_drained.notify_one(); + break; + } + }; + + process_chunk( + &mut pending, + &transcript, + &call_id, + &session_ref, + &turn_ref, + chunk, + ).await; + } + } } }); } @@ -82,9 +105,11 @@ pub(crate) fn spawn_exit_watcher( started_at: Instant, ) { let exit_token = session.cancellation_token(); + let output_drained = session.output_drained_notify(); tokio::spawn(async move { exit_token.cancelled().await; + output_drained.notified().await; let exit_code = session.exit_code().unwrap_or(-1); let duration = Instant::now().saturating_duration_since(started_at); @@ -104,6 +129,32 @@ pub(crate) fn spawn_exit_watcher( }); } +async fn process_chunk( + pending: &mut Vec, + transcript: &Arc>, + call_id: &str, + session_ref: &Arc, + turn_ref: &Arc, + chunk: Vec, +) { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.to_string(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } +} + /// Emit an ExecCommandEnd event for a unified exec session, using the transcript /// as the primary source of aggregated_output and falling back to the provided /// text when the transcript is empty. diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 51ebbd3569..4973a1a641 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -79,6 +79,7 @@ pub(crate) struct UnifiedExecSession { output_buffer: OutputBuffer, output_notify: Arc, cancellation_token: CancellationToken, + output_drained: Arc, output_task: JoinHandle<()>, sandbox_type: SandboxType, } @@ -92,27 +93,21 @@ impl UnifiedExecSession { let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); let output_notify = Arc::new(Notify::new()); let cancellation_token = CancellationToken::new(); + let output_drained = Arc::new(Notify::new()); let mut receiver = initial_output_rx; let buffer_clone = Arc::clone(&output_buffer); let notify_clone = Arc::clone(&output_notify); - let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - tokio::select! { - _ = cancellation_token_clone.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } + match receiver.recv().await { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; } }); @@ -122,6 +117,7 @@ impl UnifiedExecSession { output_buffer, output_notify, cancellation_token, + output_drained, output_task, sandbox_type, } @@ -147,6 +143,10 @@ impl UnifiedExecSession { self.cancellation_token.clone() } + pub(super) fn output_drained_notify(&self) -> Arc { + Arc::clone(&self.output_drained) + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index f50e153ddc..cc9d4ee77c 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -132,6 +132,7 @@ fn assert_posix_snapshot_sections(snapshot: &str) { async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { let command = "echo snapshot-linux"; let run = run_snapshot_command(command).await?; + let stdout = normalize_newlines(&run.end.stdout); let shell_path = run .begin @@ -150,8 +151,11 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { assert!(run.snapshot_path.starts_with(&run.codex_home)); assert_posix_snapshot_sections(&run.snapshot_content); - assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); assert_eq!(run.end.exit_code, 0); + assert!( + stdout.contains("snapshot-linux"), + "stdout should contain snapshot marker; stdout={stdout:?}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5def7aadb2..e2dcb0c567 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -228,6 +228,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { false } EventMsg::ExecCommandBegin(event) if event.call_id == call_id => { + println!("Saw it"); saw_exec_begin = true; false } @@ -893,7 +894,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_call_id = "uexec-delayed-open"; let open_args = json!({ - "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", "yield_time_ms": 10, }); @@ -910,14 +911,14 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let second_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 6000, + "yield_time_ms": 4000, }); let third_poll_call_id = "uexec-delayed-poll-3"; let third_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 10000, + "yield_time_ms": 6000, }); let responses = vec![ @@ -984,6 +985,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let mut begin_event = None; let mut end_event = None; + let mut task_completed = false; let mut terminal_events = Vec::new(); let mut delta_text = String::new(); @@ -1003,8 +1005,13 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { end_event = Some(ev); } - EventMsg::TaskComplete(_) => break, + EventMsg::TaskComplete(_) => { + task_completed = true; + } _ => {} + }; + if task_completed && end_event.is_some() { + break; } } From 97b90094cdda249e99de33b7d44a50fe6c47ea4e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:04:52 +0000 Subject: [PATCH 118/159] feat: use remote branch for review is local trails (#7813) --- codex-rs/utils/git/src/branch.rs | 140 +++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 14 deletions(-) diff --git a/codex-rs/utils/git/src/branch.rs b/codex-rs/utils/git/src/branch.rs index 543dffd9c9..f65de0c6bf 100644 --- a/codex-rs/utils/git/src/branch.rs +++ b/codex-rs/utils/git/src/branch.rs @@ -7,7 +7,8 @@ use crate::operations::resolve_head; use crate::operations::resolve_repository_root; use crate::operations::run_git_for_stdout; -/// Returns the merge-base commit between `HEAD` and the provided branch, if both exist. +/// Returns the merge-base commit between `HEAD` and the latest version between local +/// and remote of the provided branch, if both exist. /// /// The function mirrors `git merge-base HEAD ` but returns `Ok(None)` when /// the repository has no `HEAD` yet or when the branch cannot be resolved. @@ -22,26 +23,23 @@ pub fn merge_base_with_head( None => return Ok(None), }; - let branch_ref = match run_git_for_stdout( - repo_root.as_path(), - vec![ - OsString::from("rev-parse"), - OsString::from("--verify"), - OsString::from(branch), - ], - None, - ) { - Ok(rev) => rev, - Err(GitToolingError::GitCommand { .. }) => return Ok(None), - Err(other) => return Err(other), + let Some(branch_ref) = resolve_branch_ref(repo_root.as_path(), branch)? else { + return Ok(None); }; + let preferred_ref = + if let Some(upstream) = resolve_upstream_if_remote_ahead(repo_root.as_path(), branch)? { + resolve_branch_ref(repo_root.as_path(), &upstream)?.unwrap_or(branch_ref) + } else { + branch_ref + }; + let merge_base = run_git_for_stdout( repo_root.as_path(), vec![ OsString::from("merge-base"), OsString::from(head), - OsString::from(branch_ref), + OsString::from(preferred_ref), ], None, )?; @@ -49,6 +47,75 @@ pub fn merge_base_with_head( Ok(Some(merge_base)) } +fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result, GitToolingError> { + let rev = run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-parse"), + OsString::from("--verify"), + OsString::from(branch), + ], + None, + ); + + match rev { + Ok(rev) => Ok(Some(rev)), + Err(GitToolingError::GitCommand { .. }) => Ok(None), + Err(other) => Err(other), + } +} + +fn resolve_upstream_if_remote_ahead( + repo_root: &Path, + branch: &str, +) -> Result, GitToolingError> { + let upstream = match run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-parse"), + OsString::from("--abbrev-ref"), + OsString::from("--symbolic-full-name"), + OsString::from(format!("{branch}@{{upstream}}")), + ], + None, + ) { + Ok(name) => { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Ok(None); + } + trimmed.to_string() + } + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; + + let counts = match run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-list"), + OsString::from("--left-right"), + OsString::from("--count"), + OsString::from(format!("{branch}...{upstream}")), + ], + None, + ) { + Ok(counts) => counts, + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; + + let mut parts = counts.split_whitespace(); + let _left: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + let right: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + + if right > 0 { + Ok(Some(upstream)) + } else { + Ok(None) + } +} + #[cfg(test)] mod tests { use super::merge_base_with_head; @@ -126,6 +193,51 @@ mod tests { Ok(()) } + #[test] + fn merge_base_prefers_upstream_when_remote_ahead() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path().join("repo"); + let remote = temp.path().join("remote.git"); + std::fs::create_dir_all(&repo)?; + std::fs::create_dir_all(&remote)?; + + run_git_in(&remote, &["init", "--bare"]); + run_git_in(&repo, &["init", "--initial-branch=main"]); + run_git_in(&repo, &["config", "core.autocrlf", "false"]); + + std::fs::write(repo.join("base.txt"), "base\n")?; + run_git_in(&repo, &["add", "base.txt"]); + commit(&repo, "base commit"); + + run_git_in( + &repo, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + run_git_in(&repo, &["push", "-u", "origin", "main"]); + + run_git_in(&repo, &["checkout", "-b", "feature"]); + std::fs::write(repo.join("feature.txt"), "feature change\n")?; + run_git_in(&repo, &["add", "feature.txt"]); + commit(&repo, "feature commit"); + + run_git_in(&repo, &["checkout", "--orphan", "rewrite"]); + run_git_in(&repo, &["rm", "-rf", "."]); + std::fs::write(repo.join("new-main.txt"), "rewritten main\n")?; + run_git_in(&repo, &["add", "new-main.txt"]); + commit(&repo, "rewrite main"); + run_git_in(&repo, &["branch", "-M", "rewrite", "main"]); + run_git_in(&repo, &["branch", "--set-upstream-to=origin/main", "main"]); + + run_git_in(&repo, &["checkout", "feature"]); + run_git_in(&repo, &["fetch", "origin"]); + + let expected = run_git_stdout(&repo, &["merge-base", "HEAD", "origin/main"]); + let merge_base = merge_base_with_head(&repo, "main")?; + assert_eq!(merge_base, Some(expected)); + + Ok(()) + } + #[test] fn merge_base_returns_none_when_branch_missing() -> Result<(), GitToolingError> { let temp = tempdir()?; From e0fb3ca1dbea0c418cf8b3c7b76ed671d62147e3 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Wed, 10 Dec 2025 09:18:48 -0800 Subject: [PATCH 119/159] refactoring with_escalated_permissions to use SandboxPermissions instead (#7750) helpful in the future if we want more granularity for requesting escalated permissions: e.g when running in readonly sandbox, model can request to escalate to a sandbox that allows writes --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/core/gpt-5.1-codex-max_prompt.md | 6 +- codex-rs/core/gpt_5_1_prompt.md | 6 +- codex-rs/core/gpt_5_codex_prompt.md | 6 +- codex-rs/core/src/codex.rs | 13 +- codex-rs/core/src/exec.rs | 15 +- codex-rs/core/src/sandboxing/mod.rs | 29 +--- codex-rs/core/src/tasks/user_shell.rs | 3 +- codex-rs/core/src/tools/handlers/shell.rs | 23 ++- .../core/src/tools/handlers/unified_exec.rs | 9 +- codex-rs/core/src/tools/router.rs | 3 +- .../core/src/tools/runtimes/apply_patch.rs | 3 +- codex-rs/core/src/tools/runtimes/mod.rs | 5 +- codex-rs/core/src/tools/runtimes/shell.rs | 11 +- .../core/src/tools/runtimes/unified_exec.rs | 15 +- codex-rs/core/src/tools/spec.rs | 24 ++-- codex-rs/core/src/unified_exec/mod.rs | 5 +- .../core/src/unified_exec/session_manager.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 131 ++++++++++-------- codex-rs/core/tests/suite/codex_delegate.rs | 3 +- codex-rs/core/tests/suite/exec.rs | 3 +- codex-rs/core/tests/suite/tools.rs | 3 +- codex-rs/exec-server/src/posix.rs | 16 ++- .../exec-server/src/posix/escalate_server.rs | 3 +- .../src/posix/mcp_escalation_policy.rs | 13 +- .../linux-sandbox/tests/suite/landlock.rs | 5 +- codex-rs/protocol/src/models.rs | 31 ++++- 27 files changed, 216 insertions(+), 179 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0a8445055d..8576c5c381 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -136,6 +136,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; +use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; @@ -1191,7 +1192,7 @@ impl CodexMessageProcessor { cwd, expiration: timeout_ms.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/gpt-5.1-codex-max_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md index 292e5d7d0f..a8227c893f 100644 --- a/codex-rs/core/gpt-5.1-codex-max_prompt.md +++ b/codex-rs/core/gpt-5.1-codex-max_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index 97a3875fe5..3201ffeb68 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -182,7 +182,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -193,8 +193,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Validating your work diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 57d06761ba..e2f9017874 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 042ae1a37a..4129bb6a1f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3325,6 +3325,7 @@ mod tests { use crate::exec::ExecParams; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; use std::collections::HashMap; @@ -3335,6 +3336,7 @@ mod tests { let mut turn_context = Arc::new(turn_context_raw); let timeout_ms = 1000; + let sandbox_permissions = SandboxPermissions::RequireEscalated; let params = ExecParams { command: if cfg!(windows) { vec![ @@ -3352,13 +3354,13 @@ mod tests { cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), - with_escalated_permissions: Some(true), + sandbox_permissions, justification: Some("test".to_string()), arg0: None, }; let params2 = ExecParams { - with_escalated_permissions: Some(false), + sandbox_permissions: SandboxPermissions::UseDefault, command: params.command.clone(), cwd: params.cwd.clone(), expiration: timeout_ms.into(), @@ -3385,7 +3387,7 @@ mod tests { "command": params.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params.expiration.timeout_ms(), - "with_escalated_permissions": params.with_escalated_permissions, + "sandbox_permissions": params.sandbox_permissions, "justification": params.justification.clone(), }) .to_string(), @@ -3422,7 +3424,7 @@ mod tests { "command": params2.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params2.expiration.timeout_ms(), - "with_escalated_permissions": params2.with_escalated_permissions, + "sandbox_permissions": params2.sandbox_permissions, "justification": params2.justification.clone(), }) .to_string(), @@ -3455,6 +3457,7 @@ mod tests { #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::protocol::AskForApproval; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; let (session, mut turn_context_raw) = make_session_and_context(); @@ -3474,7 +3477,7 @@ mod tests { payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, "justification": "need unsandboxed execution", }) .to_string(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index ba1ac43004..596f325059 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -28,6 +28,7 @@ use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxPermissions; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; @@ -55,7 +56,7 @@ pub struct ExecParams { pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -144,7 +145,7 @@ pub async fn process_exec_tool_call( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0: _, } = params; @@ -162,7 +163,7 @@ pub async fn process_exec_tool_call( cwd, env, expiration, - with_escalated_permissions, + sandbox_permissions, justification, }; @@ -192,7 +193,7 @@ pub(crate) async fn execute_exec_env( env, expiration, sandbox, - with_escalated_permissions, + sandbox_permissions, justification, arg0, } = env; @@ -202,7 +203,7 @@ pub(crate) async fn execute_exec_env( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0, }; @@ -857,7 +858,7 @@ mod tests { cwd: std::env::current_dir()?, expiration: 500.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -902,7 +903,7 @@ mod tests { cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d43646021e..3f56ce3ae9 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -23,32 +23,11 @@ use crate::seatbelt::create_seatbelt_command_args; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; +pub use codex_protocol::models::SandboxPermissions; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SandboxPermissions { - UseDefault, - RequireEscalated, -} - -impl SandboxPermissions { - pub fn requires_escalated_permissions(self) -> bool { - matches!(self, SandboxPermissions::RequireEscalated) - } -} - -impl From for SandboxPermissions { - fn from(with_escalated_permissions: bool) -> Self { - if with_escalated_permissions { - SandboxPermissions::RequireEscalated - } else { - SandboxPermissions::UseDefault - } - } -} - #[derive(Debug)] pub struct CommandSpec { pub program: String, @@ -56,7 +35,7 @@ pub struct CommandSpec { pub cwd: PathBuf, pub env: HashMap, pub expiration: ExecExpiration, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -67,7 +46,7 @@ pub struct ExecEnv { pub env: HashMap, pub expiration: ExecExpiration, pub sandbox: SandboxType, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -181,7 +160,7 @@ impl SandboxManager { env, expiration: spec.expiration, sandbox, - with_escalated_permissions: spec.with_escalated_permissions, + sandbox_permissions: spec.sandbox_permissions, justification: spec.justification, arg0: arg0_override, }) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index ca5243241a..aec09514ca 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -24,6 +24,7 @@ use crate::protocol::ExecCommandSource; use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::user_shell_command::user_shell_command_record_item; @@ -100,7 +101,7 @@ impl SessionTask for UserShellCommandTask { // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), sandbox: SandboxType::None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 98bd883d13..9c306a186e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -10,7 +10,6 @@ use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; -use crate::sandboxing::SandboxPermissions; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -35,7 +34,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -56,7 +55,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -206,7 +205,9 @@ impl ShellHandler { freeform: bool, ) -> Result { // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.with_escalated_permissions.unwrap_or(false) + if exec_params + .sandbox_permissions + .requires_escalated_permissions() && !matches!( turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -251,7 +252,7 @@ impl ShellHandler { &exec_params.command, turn.approval_policy, &turn.sandbox_policy, - SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), + exec_params.sandbox_permissions, ) .await; @@ -260,7 +261,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), - with_escalated_permissions: exec_params.with_escalated_permissions, + sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -295,6 +296,7 @@ mod tests { use crate::codex::make_session_and_context; use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; + use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::handlers::ShellCommandHandler; @@ -343,7 +345,7 @@ mod tests { let workdir = Some("subdir".to_string()); let login = None; let timeout_ms = Some(1234); - let with_escalated_permissions = Some(true); + let sandbox_permissions = SandboxPermissions::RequireEscalated; let justification = Some("because tests".to_string()); let expected_command = session.user_shell().derive_exec_args(&command, true); @@ -355,7 +357,7 @@ mod tests { workdir, login, timeout_ms, - with_escalated_permissions, + sandbox_permissions: Some(sandbox_permissions), justification: justification.clone(), }; @@ -366,10 +368,7 @@ mod tests { assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); - assert_eq!( - exec_params.with_escalated_permissions, - with_escalated_permissions - ); + assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); assert_eq!(exec_params.arg0, None); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index abaaf4a7ab..0d3a11da10 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -3,6 +3,7 @@ use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; use crate::protocol::TerminalInteractionEvent; +use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -40,7 +41,7 @@ struct ExecCommandArgs { #[serde(default)] max_output_tokens: Option, #[serde(default)] - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, #[serde(default)] justification: Option, } @@ -131,12 +132,12 @@ impl ToolHandler for UnifiedExecHandler { login, yield_time_ms, max_output_tokens, - with_escalated_permissions, + sandbox_permissions, justification, .. } = args; - if with_escalated_permissions.unwrap_or(false) + if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -200,7 +201,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - with_escalated_permissions, + sandbox_permissions, justification, }, &context, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7152d3c1ec..b6675bcd5d 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,6 +5,7 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::sandboxing::SandboxPermissions; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -114,7 +115,7 @@ impl ToolRouter { command: exec.command, workdir: exec.working_directory, timeout_ms: exec.timeout_ms, - with_escalated_permissions: None, + sandbox_permissions: Some(SandboxPermissions::UseDefault), justification: None, }; Ok(Some(ToolCall { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 7ef8d33767..bf4b66ce9d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -7,6 +7,7 @@ use crate::CODEX_APPLY_PATCH_ARG1; use crate::exec::ExecToolCallOutput; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -70,7 +71,7 @@ impl ApplyPatchRuntime { expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 437f4af428..2431b3c97d 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -6,6 +6,7 @@ small and focused and reuses the orchestrator for approvals + sandbox + retry. */ use crate::exec::ExecExpiration; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ToolError; use std::collections::HashMap; use std::path::Path; @@ -21,7 +22,7 @@ pub(crate) fn build_command_spec( cwd: &Path, env: &HashMap, expiration: ExecExpiration, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, ) -> Result { let (program, args) = command @@ -33,7 +34,7 @@ pub(crate) fn build_command_spec( cwd: cwd.to_path_buf(), env: env.clone(), expiration, - with_escalated_permissions, + sandbox_permissions, justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 50b6a6785a..595bda0e96 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -5,6 +5,7 @@ Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; @@ -30,7 +31,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -51,7 +52,7 @@ pub struct ShellRuntime; pub(crate) struct ApprovalKey { command: Vec, cwd: PathBuf, - escalated: bool, + sandbox_permissions: SandboxPermissions, } impl ShellRuntime { @@ -84,7 +85,7 @@ impl Approvable for ShellRuntime { ApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -129,7 +130,7 @@ impl Approvable for ShellRuntime { } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -157,7 +158,7 @@ impl ToolRuntime for ShellRuntime { &req.cwd, &req.env, req.timeout_ms.into(), - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), )?; let env = attempt diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index d21e6de1e2..b6a8047080 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,6 +7,7 @@ the session manager to spawn PTYs once an ExecEnv is prepared. use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; +use crate::sandboxing::SandboxPermissions; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -34,7 +35,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -52,7 +53,7 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathBuf, - pub escalated: bool, + pub sandbox_permissions: SandboxPermissions, } pub struct UnifiedExecRuntime<'a> { @@ -64,7 +65,7 @@ impl UnifiedExecRequest { command: Vec, cwd: PathBuf, env: HashMap, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, exec_approval_requirement: ExecApprovalRequirement, ) -> Self { @@ -72,7 +73,7 @@ impl UnifiedExecRequest { command, cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, } @@ -102,7 +103,7 @@ impl Approvable for UnifiedExecRuntime<'_> { UnifiedExecApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -150,7 +151,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -178,7 +179,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &req.env, ExecExpiration::DefaultTimeout, - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 89a71b0edc..0b74b9e10a 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -174,10 +174,10 @@ fn create_exec_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { + "sandbox_permissions".to_string(), + JsonSchema::String { description: Some( - "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions" + "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." .to_string(), ), }, @@ -186,7 +186,7 @@ fn create_exec_command_tool() -> ToolSpec { "justification".to_string(), JsonSchema::String { description: Some( - "Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command." + "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command." .to_string(), ), }, @@ -274,15 +274,15 @@ fn create_shell_tool() -> ToolSpec { ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); properties.insert( "justification".to_string(), JsonSchema::String { - description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); @@ -347,15 +347,15 @@ fn create_shell_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); properties.insert( "justification".to_string(), JsonSchema::String { - description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 0d86b69fda..814001f41f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,6 +33,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +use crate::sandboxing::SandboxPermissions; mod async_watcher; mod errors; @@ -93,7 +94,7 @@ pub(crate) struct ExecCommandRequest { pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -217,7 +218,7 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }, &context, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index fa64eb4bb2..4b24c574ac 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -126,7 +126,7 @@ impl UnifiedExecSessionManager { .open_session_with_sandbox( &request.command, cwd.clone(), - request.with_escalated_permissions, + request.sandbox_permissions, request.justification, context, ) @@ -476,7 +476,7 @@ impl UnifiedExecSessionManager { &self, command: &[String], cwd: PathBuf, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, context: &UnifiedExecContext, ) -> Result { @@ -490,14 +490,14 @@ impl UnifiedExecSessionManager { command, context.turn.approval_policy, &context.turn.sandbox_policy, - SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), + sandbox_permissions, ) .await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, ); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 4570e6a5b9..879ad56d47 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -9,6 +9,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::user_input::UserInput; @@ -96,14 +97,14 @@ impl ActionKind { test: &TestCodex, server: &MockServer, call_id: &str, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let command = format!("printf {content:?} > {path:?} && cat {path:?}"); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::FetchUrl { @@ -125,11 +126,11 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { - let event = shell_event(call_id, command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, command, 1_000, sandbox_permissions)?; Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { @@ -140,7 +141,7 @@ impl ActionKind { call_id, command, Some(1000), - with_escalated_permissions, + sandbox_permissions, *justification, )?; Ok((event, Some(command.to_string()))) @@ -156,7 +157,7 @@ impl ActionKind { let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); let command = shell_apply_patch_command(&patch); - let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } } @@ -181,14 +182,14 @@ fn shell_event( call_id: &str, command: &str, timeout_ms: u64, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result { let mut args = json!({ "command": command, "timeout_ms": timeout_ms, }); - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "shell_command", &args_str)) @@ -198,7 +199,7 @@ fn exec_command_event( call_id: &str, cmd: &str, yield_time_ms: Option, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, justification: Option<&str>, ) -> Result { let mut args = json!({ @@ -207,8 +208,8 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); } @@ -466,7 +467,7 @@ struct ScenarioSpec { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, action: ActionKind, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, features: Vec, model_override: Option<&'static str>, outcome: Outcome, @@ -637,7 +638,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -654,7 +655,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -671,7 +672,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -687,7 +688,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -702,7 +703,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -717,7 +718,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -733,7 +734,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -750,7 +751,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -767,7 +768,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -787,7 +788,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -807,7 +808,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -824,7 +825,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -841,7 +842,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -861,7 +862,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -880,7 +881,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -895,7 +896,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -911,7 +912,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-blocked", response_body: "should-not-see", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -925,7 +926,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: None, outcome: Outcome::ExecApproval { @@ -946,7 +947,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -967,7 +968,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure_5_1.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -987,7 +988,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1006,7 +1007,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1025,7 +1026,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1045,7 +1046,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function.txt"), content: "function-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1062,7 +1063,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), content: "function-patch-danger", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1079,7 +1080,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), content: "function-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1099,7 +1100,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), content: "function-patch-outside-denied", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1119,7 +1120,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1139,7 +1140,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), content: "function-patch-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1159,7 +1160,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), content: "function-patch-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1178,7 +1179,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1198,7 +1199,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1218,7 +1219,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1241,7 +1242,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1257,7 +1258,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1274,7 +1275,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-blocked", response_body: "workspace-network-blocked", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1288,7 +1289,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1308,7 +1309,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-ok", response_body: "workspace-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1325,7 +1326,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), content: "workspace-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1345,7 +1346,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), content: "workspace-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1365,7 +1366,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_never.txt"), content: "workspace-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1389,7 +1390,7 @@ fn scenarios() -> Vec { command: "echo \"hello unified exec\"", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1407,7 +1408,7 @@ fn scenarios() -> Vec { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1426,7 +1427,7 @@ fn scenarios() -> Vec { command: "git reset --hard", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: None, outcome: Outcome::ExecApproval { @@ -1472,7 +1473,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let call_id = scenario.name; let (event, expected_command) = scenario .action - .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; let _ = mount_sse_once( @@ -1578,7 +1579,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (first_event, expected_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_first, false) + .prepare( + &test, + &server, + call_id_first, + SandboxPermissions::UseDefault, + ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); @@ -1656,7 +1662,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (second_event, second_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_second, false) + .prepare( + &test, + &server, + call_id_second, + SandboxPermissions::UseDefault, + ) .await?; assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f5fe1a7df9..2bd156d6a8 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -31,7 +32,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { let args = serde_json::json!({ "command": "rm -rf delegated", "timeout_ms": 1000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }) .to_string(); let sse1 = sse(vec![ diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6c2283107b..c093482157 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -8,6 +8,7 @@ use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use tempfile::TempDir; @@ -34,7 +35,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<()> { let first_args = json!({ "command": command, "timeout_ms": 1_000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 1a4b0a0e1f..ba481264e2 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -63,6 +63,7 @@ use anyhow::Context as _; use clap::Parser; use codex_core::config::find_codex_home; use codex_core::is_dangerous_command::command_might_be_dangerous; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; @@ -202,13 +203,19 @@ pub(crate) fn evaluate_exec_policy( && rule_match.decision() == evaluation.decision }); + let sandbox_permissions = if decision_driven_by_policy { + SandboxPermissions::RequireEscalated + } else { + SandboxPermissions::UseDefault + }; + Ok(match evaluation.decision { Decision::Forbidden => ExecPolicyOutcome::Forbidden, Decision::Prompt => ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, Decision::Allow => ExecPolicyOutcome::Allow { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, }) } @@ -231,6 +238,7 @@ async fn load_exec_policy() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; @@ -247,7 +255,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: false + sandbox_permissions: SandboxPermissions::UseDefault } ); } @@ -276,7 +284,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Allow { - run_with_escalated_permissions: true + sandbox_permissions: SandboxPermissions::RequireEscalated } ); } diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 72934607a3..d99f300704 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -10,6 +10,7 @@ use path_absolutize::Absolutize as _; use codex_core::SandboxState; use codex_core::exec::process_exec_tool_call; +use codex_core::sandboxing::SandboxPermissions; use tokio::process::Command; use tokio_util::sync::CancellationToken; @@ -85,7 +86,7 @@ impl EscalateServer { cwd: PathBuf::from(&workdir), expiration: ExecExpiration::Cancellation(cancel_rx), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }, diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 97e76a6844..6d0c1bb338 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; @@ -18,10 +19,10 @@ use tokio::sync::RwLock; #[derive(Debug, PartialEq, Eq)] pub(crate) enum ExecPolicyOutcome { Allow { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Prompt { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Forbidden, } @@ -108,16 +109,16 @@ impl EscalationPolicy for McpEscalationPolicy { crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { - run_with_escalated_permissions, + sandbox_permissions, } => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run } } ExecPolicyOutcome::Prompt { - run_with_escalated_permissions, + sandbox_permissions, } => { let result = self .prompt(file, argv, workdir, self.context.clone()) @@ -125,7 +126,7 @@ impl EscalationPolicy for McpEscalationPolicy { // TODO: Extract reason from `result.content`. match result.action { ElicitationAction::Accept => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index e145aa2f73..791f9b1ea7 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -6,6 +6,7 @@ use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -41,7 +42,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { cwd, expiration: timeout_ms.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -143,7 +144,7 @@ async fn assert_network_blocked(cmd: &[&str]) { // do not stall the suite. expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 9f66d08dca..51e977cb95 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -14,6 +14,25 @@ use codex_git::GhostCommit; use codex_utils_image::error::ImageProcessingError; use schemars::JsonSchema; +/// Controls whether a command should use the session sandbox or bypass it. +#[derive( + Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(rename_all = "snake_case")] +pub enum SandboxPermissions { + /// Run with the configured sandbox + #[default] + UseDefault, + /// Request to run outside the sandbox + RequireEscalated, +} + +impl SandboxPermissions { + pub fn requires_escalated_permissions(self) -> bool { + matches!(self, SandboxPermissions::RequireEscalated) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { @@ -327,8 +346,9 @@ pub struct ShellToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -346,8 +366,9 @@ pub struct ShellCommandToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -742,7 +763,7 @@ mod tests { command: vec!["ls".to_string(), "-l".to_string()], workdir: Some("/tmp".to_string()), timeout_ms: Some(1000), - with_escalated_permissions: None, + sandbox_permissions: None, justification: None, }, params From c4af707e09b91cae7ccb83569596d1725eeefebe Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 10 Dec 2025 11:48:11 -0600 Subject: [PATCH 120/159] Removed experimental "command risk assessment" feature (#7799) This experimental feature received lukewarm reception during internal testing. Removing from the code base. --- codex-rs/Cargo.lock | 52 ---- codex-rs/Cargo.toml | 1 - .../src/protocol/common.rs | 2 - .../app-server-protocol/src/protocol/v1.rs | 2 - .../app-server-protocol/src/protocol/v2.rs | 37 --- codex-rs/app-server-test-client/src/main.rs | 4 - .../app-server/src/bespoke_event_handling.rs | 6 +- .../suite/codex_message_processor_flow.rs | 1 - codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 31 -- codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/config/mod.rs | 14 - codex-rs/core/src/config/profile.rs | 1 - codex-rs/core/src/features.rs | 12 - codex-rs/core/src/features/legacy.rs | 11 - codex-rs/core/src/sandboxing/assessment.rs | 268 ------------------ codex-rs/core/src/sandboxing/mod.rs | 2 - codex-rs/core/src/tools/orchestrator.rs | 39 --- .../core/src/tools/runtimes/apply_patch.rs | 10 - codex-rs/core/src/tools/runtimes/shell.rs | 13 - .../core/src/tools/runtimes/unified_exec.rs | 13 - codex-rs/core/src/tools/sandboxing.rs | 14 - .../templates/sandboxing/assessment_prompt.md | 24 -- codex-rs/exec/src/lib.rs | 1 - codex-rs/mcp-server/src/codex_tool_config.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 2 - codex-rs/mcp-server/src/exec_approval.rs | 5 - codex-rs/mcp-server/tests/suite/codex_tool.rs | 1 - codex-rs/otel/src/otel_event_manager.rs | 47 --- codex-rs/protocol/src/approvals.rs | 27 -- codex-rs/protocol/src/protocol.rs | 2 - .../tui/src/bottom_pane/approval_overlay.rs | 36 --- codex-rs/tui/src/bottom_pane/mod.rs | 1 - codex-rs/tui/src/chatwidget.rs | 1 - ...hatwidget__tests__approval_modal_exec.snap | 2 + ...roval_history_decision_approved_short.snap | 1 - codex-rs/tui/src/chatwidget/tests.rs | 6 - codex-rs/tui/src/lib.rs | 1 - docs/config.md | 21 +- docs/example-config.md | 2 - 40 files changed, 13 insertions(+), 703 deletions(-) delete mode 100644 codex-rs/core/src/sandboxing/assessment.rs delete mode 100644 codex-rs/core/templates/sandboxing/assessment_prompt.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bca96ff631..8ee790f676 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -238,48 +238,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.104", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -557,15 +515,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "beef" version = "0.5.2" @@ -1137,7 +1086,6 @@ name = "codex-core" version = "0.0.0" dependencies = [ "anyhow", - "askama", "assert_cmd", "assert_matches", "async-channel", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2521e3bda..cdf55434fe 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -109,7 +109,6 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bd9f6ddedf..116a3c62dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -654,7 +654,6 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], @@ -674,7 +673,6 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", - "risk": null, "parsedCmd": [ { "type": "unknown", diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 1576eb0d93..853cb03b40 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -13,7 +13,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; @@ -226,7 +225,6 @@ pub struct ExecCommandApprovalParams { pub command: Vec, pub cwd: PathBuf, pub reason: Option, - pub risk: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index db987e27df..211f0ba375 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -275,14 +274,6 @@ pub struct ConfigEdit { pub merge_strategy: MergeStrategy, } -v2_enum_from_core!( - pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel { - Low, - Medium, - High - } -); - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -362,32 +353,6 @@ impl From for SandboxPolicy { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: CommandRiskLevel, -} - -impl SandboxCommandAssessment { - pub fn into_core(self) -> CoreSandboxCommandAssessment { - CoreSandboxCommandAssessment { - description: self.description, - risk_level: self.risk_level.to_core(), - } - } -} - -impl From for SandboxCommandAssessment { - fn from(value: CoreSandboxCommandAssessment) -> Self { - Self { - description: value.description, - risk_level: CommandRiskLevel::from(value.risk_level), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "Array", export_to = "v2/")] @@ -1535,8 +1500,6 @@ pub struct CommandExecutionRequestApprovalParams { pub item_id: String, /// Optional explanatory reason (e.g. request for network access). pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - pub risk: Option, /// Optional proposed execpolicy amendment to allow similar commands without prompting. pub proposed_execpolicy_amendment: Option, } diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 924740896e..b66c59d55a 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -756,7 +756,6 @@ impl CodexClient { turn_id, item_id, reason, - risk, proposed_execpolicy_amendment, } = params; @@ -766,9 +765,6 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } - if let Some(risk) = risk.as_ref() { - println!("< risk assessment: {risk:?}"); - } if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8956aedd13..b0161cd9fd 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningTextDeltaNotification; -use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::TerminalInteractionNotification; @@ -180,7 +179,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { @@ -191,7 +189,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, parsed_cmd, }; let rx = outgoing @@ -219,7 +216,6 @@ pub(crate) async fn apply_bespoke_event_handling( // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. item_id: item_id.clone(), reason, - risk: risk.map(V2SandboxCommandAssessment::from), proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing @@ -1214,7 +1210,7 @@ async fn construct_mcp_tool_call_notification( } } -/// simiilar to handle_mcp_tool_call_end in exec +/// similar to handle_mcp_tool_call_end in exec async fn construct_mcp_tool_call_end_notification( end_event: McpToolCallEndEvent, thread_id: String, diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 4b206436c8..e417198994 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -271,7 +271,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { command: format_with_current_shell("python3 -c 'print(42)'"), cwd: working_directory.clone(), reason: None, - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2bc281d903..a11e7c24d6 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] anyhow = { workspace = true } -askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4129bb6a1f..6f637d143c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -95,7 +95,6 @@ use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; @@ -875,34 +874,6 @@ impl Session { .await; } - pub(crate) async fn assess_sandbox_command( - &self, - turn_context: &TurnContext, - call_id: &str, - command: &[String], - failure_message: Option<&str>, - ) -> Option { - let config = turn_context.client.config(); - let provider = turn_context.client.provider().clone(); - let auth_manager = Arc::clone(&self.services.auth_manager); - let otel = self.services.otel_event_manager.clone(); - crate::sandboxing::assessment::assess_command( - config, - provider, - auth_manager, - &otel, - self.conversation_id, - self.services.models_manager.clone(), - turn_context.client.get_session_source(), - call_id, - command, - &turn_context.sandbox_policy, - &turn_context.cwd, - failure_message, - ) - .await - } - /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future /// commands can use the newly approved prefix. pub(crate) async fn persist_execpolicy_amendment( @@ -950,7 +921,6 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); @@ -978,7 +948,6 @@ impl Session { command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 670225ead0..75b29eddee 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -280,7 +280,6 @@ async fn handle_exec_approval( event.command, event.cwd, event.reason, - event.risk, event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8db08c55a2..bdf7a54177 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -246,9 +246,6 @@ pub struct Config { pub tools_web_search_request: bool, - /// When `true`, run a model-based assessment for commands denied by the sandbox. - pub experimental_sandbox_command_assessment: bool, - /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -733,7 +730,6 @@ pub struct ConfigToml { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -919,7 +915,6 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -978,7 +973,6 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, } = overrides; @@ -1003,7 +997,6 @@ impl Config { let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); @@ -1102,8 +1095,6 @@ impl Config { let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); - let experimental_sandbox_command_assessment = - features.enabled(Feature::SandboxCommandAssessment); let forced_chatgpt_workspace_id = cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { @@ -1234,7 +1225,6 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - experimental_sandbox_command_assessment, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, features, @@ -2990,7 +2980,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3065,7 +3054,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3155,7 +3143,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3231,7 +3218,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 5629465c40..978e1fcb63 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,7 +27,6 @@ pub struct ConfigProfile { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, pub tools_web_search: Option, pub tools_view_image: Option, /// Optional feature toggles scoped to this profile. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d714f8e85e..4b8370039a 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -48,8 +48,6 @@ pub enum Feature { WebSearchRequest, /// Gate the execpolicy enforcement for shell/unified exec. ExecPolicy, - /// Enable the model-based risk assessments for sandboxed commands. - SandboxCommandAssessment, /// Enable Windows sandbox (restricted token) on Windows. WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) @@ -104,7 +102,6 @@ pub struct Features { pub struct FeatureOverrides { pub include_apply_patch_tool: Option, pub web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, } impl FeatureOverrides { @@ -196,7 +193,6 @@ impl Features { let mut features = Features::with_defaults(); let base_legacy = LegacyFeatureToggles { - experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, @@ -212,8 +208,6 @@ impl Features { let profile_legacy = LegacyFeatureToggles { include_apply_patch_tool: config_profile.include_apply_patch_tool, - experimental_sandbox_command_assessment: config_profile - .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, @@ -327,12 +321,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, - FeatureSpec { - id: Feature::SandboxCommandAssessment, - key: "experimental_sandbox_command_assessment", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::WindowsSandbox, key: "enable_experimental_windows_sandbox", diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 4d59f2a9a3..0c74d380e8 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,10 +9,6 @@ struct Alias { } const ALIASES: &[Alias] = &[ - Alias { - legacy_key: "experimental_sandbox_command_assessment", - feature: Feature::SandboxCommandAssessment, - }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, @@ -48,7 +44,6 @@ pub(crate) fn feature_for_key(key: &str) -> Option { #[derive(Debug, Default)] pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, - pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, @@ -64,12 +59,6 @@ impl LegacyFeatureToggles { self.include_apply_patch_tool, "include_apply_patch_tool", ); - set_if_some( - features, - Feature::SandboxCommandAssessment, - self.experimental_sandbox_command_assessment, - "experimental_sandbox_command_assessment", - ); set_if_some( features, Feature::ApplyPatchFreeform, diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs deleted file mode 100644 index b7a9c952d1..0000000000 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use crate::AuthManager; -use crate::ModelProviderInfo; -use crate::client::ModelClient; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::config::Config; -use crate::openai_models::models_manager::ModelsManager; -use crate::protocol::SandboxPolicy; -use askama::Template; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::ConversationId; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxCommandAssessment; -use codex_protocol::protocol::SessionSource; -use futures::StreamExt; -use serde_json::json; -use tokio::time::timeout; -use tracing::warn; - -const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(15); -const SANDBOX_ASSESSMENT_REASONING_EFFORT: ReasoningEffortConfig = ReasoningEffortConfig::Medium; - -#[derive(Template)] -#[template(path = "sandboxing/assessment_prompt.md", escape = "none")] -struct SandboxAssessmentPromptTemplate<'a> { - platform: &'a str, - sandbox_policy: &'a str, - filesystem_roots: Option<&'a str>, - working_directory: &'a str, - command_argv: &'a str, - command_joined: &'a str, - sandbox_failure_message: Option<&'a str>, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn assess_command( - config: Arc, - provider: ModelProviderInfo, - auth_manager: Arc, - parent_otel: &OtelEventManager, - conversation_id: ConversationId, - models_manager: Arc, - session_source: SessionSource, - call_id: &str, - command: &[String], - sandbox_policy: &SandboxPolicy, - cwd: &Path, - failure_message: Option<&str>, -) -> Option { - if !config.experimental_sandbox_command_assessment || command.is_empty() { - return None; - } - - let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string()); - let command_joined = - shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); - let failure = failure_message - .map(str::trim) - .filter(|msg| !msg.is_empty()) - .map(str::to_string); - - let cwd_str = cwd.to_string_lossy().to_string(); - let sandbox_summary = summarize_sandbox_policy(sandbox_policy); - let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd); - roots.sort(); - roots.dedup(); - - let platform = std::env::consts::OS; - let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string()); - let filesystem_roots = match roots_formatted.collect::>() { - collected if collected.is_empty() => None, - collected => Some(collected.join(", ")), - }; - - let prompt_template = SandboxAssessmentPromptTemplate { - platform, - sandbox_policy: sandbox_summary.as_str(), - filesystem_roots: filesystem_roots.as_deref(), - working_directory: cwd_str.as_str(), - command_argv: command_json.as_str(), - command_joined: command_joined.as_str(), - sandbox_failure_message: failure.as_deref(), - }; - let rendered_prompt = match prompt_template.render() { - Ok(rendered) => rendered, - Err(err) => { - warn!("failed to render sandbox assessment prompt: {err}"); - return None; - } - }; - let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") { - Some(split) => split, - None => { - warn!("rendered sandbox assessment prompt missing separator"); - return None; - } - }; - let system_prompt = system_prompt_section - .strip_prefix("System Prompt:\n") - .unwrap_or(system_prompt_section) - .trim() - .to_string(); - let user_prompt = user_prompt_section - .strip_prefix("User Prompt:\n") - .unwrap_or(user_prompt_section) - .trim() - .to_string(); - - let prompt = Prompt { - input: vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_prompt }], - }], - tools: Vec::new(), - parallel_tool_calls: false, - base_instructions_override: Some(system_prompt), - output_schema: Some(sandbox_assessment_schema()), - }; - - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; - - let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); - - let client = ModelClient::new( - Arc::clone(&config), - Some(auth_manager), - model_family, - child_otel, - provider, - Some(SANDBOX_ASSESSMENT_REASONING_EFFORT), - config.model_reasoning_summary, - conversation_id, - session_source, - ); - - let start = Instant::now(); - let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move { - let mut stream = client.stream(&prompt).await?; - let mut last_json: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputItemDone(item)) => { - if let Some(text) = response_item_text(&item) { - last_json = Some(text); - } - } - Ok(ResponseEvent::RateLimits(_)) => {} - Ok(ResponseEvent::Completed { .. }) => break, - Ok(_) => continue, - Err(err) => return Err(err), - } - } - Ok(last_json) - }) - .await; - let duration = start.elapsed(); - parent_otel.sandbox_assessment_latency(call_id, duration); - - match assessment_result { - Ok(Ok(Some(raw))) => match serde_json::from_str::(raw.trim()) { - Ok(assessment) => { - parent_otel.sandbox_assessment( - call_id, - "success", - Some(assessment.risk_level), - duration, - ); - return Some(assessment); - } - Err(err) => { - warn!("failed to parse sandbox assessment JSON: {err}"); - parent_otel.sandbox_assessment(call_id, "parse_error", None, duration); - } - }, - Ok(Ok(None)) => { - warn!("sandbox assessment response did not include any message"); - parent_otel.sandbox_assessment(call_id, "no_output", None, duration); - } - Ok(Err(err)) => { - warn!("sandbox assessment failed: {err}"); - parent_otel.sandbox_assessment(call_id, "model_error", None, duration); - } - Err(_) => { - warn!("sandbox assessment timed out"); - parent_otel.sandbox_assessment(call_id, "timeout", None, duration); - } - } - - None -} - -fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String { - match policy { - SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), - SandboxPolicy::WorkspaceWrite { network_access, .. } => { - let network = if *network_access { - "network" - } else { - "no-network" - }; - format!("workspace-write (network_access={network})") - } - } -} - -fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec { - let mut roots = vec![cwd.to_path_buf()]; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - roots.extend(writable_roots.iter().cloned()); - } - roots -} - -fn sandbox_assessment_schema() -> serde_json::Value { - json!({ - "type": "object", - "required": ["description", "risk_level"], - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "risk_level": { - "type": "string", - "enum": ["low", "medium", "high"] - }, - }, - "additionalProperties": false - }) -} - -fn response_item_text(item: &ResponseItem) -> Option { - match item { - ResponseItem::Message { content, .. } => { - let mut buffers: Vec<&str> = Vec::new(); - for segment in content { - match segment { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - if !text.is_empty() { - buffers.push(text); - } - } - ContentItem::InputImage { .. } => {} - } - } - if buffers.is_empty() { - None - } else { - Some(buffers.join("\n")) - } - } - ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()), - _ => None, - } -} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 3f56ce3ae9..5d719a7922 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub mod assessment; - use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4c34658fcd..003c727610 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -7,12 +7,10 @@ retry without sandbox on denial (no re‑approval thanks to caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; -use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; @@ -43,7 +41,6 @@ impl ToolOrchestrator { ) -> Result where T: ToolRuntime, - Rq: ProvidesSandboxRetryData, { let otel = turn_ctx.client.get_otel_event_manager(); let otel_tn = &tool_ctx.tool_name; @@ -65,26 +62,11 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ExecApprovalRequirement::NeedsApproval { reason, .. } => { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - None, - ) - .await; - } - let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -141,33 +123,12 @@ impl ToolOrchestrator { // Ask for approval before retrying without sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - let err = SandboxErr::Denied { - output: output.clone(), - }; - let friendly = get_error_message_ui(&CodexErr::Sandbox(err)); - let failure_summary = format!("failed in sandbox: {friendly}"); - - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - Some(failure_summary.as_str()), - ) - .await; - } - let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(reason_msg), - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index bf4b66ce9d..26d04f578c 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,9 +11,7 @@ use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -35,12 +33,6 @@ pub struct ApplyPatchRequest { pub codex_exe: Option, } -impl ProvidesSandboxRetryData for ApplyPatchRequest { - fn sandbox_retry_data(&self) -> Option { - None - } -} - #[derive(Default)] pub struct ApplyPatchRuntime; @@ -115,7 +107,6 @@ impl Approvable for ApplyPatchRuntime { let call_id = ctx.call_id.to_string(); let cwd = req.cwd.clone(); let retry_reason = ctx.retry_reason.clone(); - let risk = ctx.risk.clone(); let user_explicitly_approved = req.user_explicitly_approved; Box::pin(async move { with_cached_approval(&session.services, key, move || async move { @@ -127,7 +118,6 @@ impl Approvable for ApplyPatchRuntime { vec!["apply_patch".to_string()], cwd, Some(reason), - risk, None, ) .await diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 595bda0e96..078be68e89 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,10 +11,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -36,15 +34,6 @@ pub struct ShellRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for ShellRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(Default)] pub struct ShellRuntime; @@ -101,7 +90,6 @@ impl Approvable for ShellRuntime { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); @@ -114,7 +102,6 @@ impl Approvable for ShellRuntime { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b6a8047080..3d35987c7e 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -12,10 +12,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -40,15 +38,6 @@ pub struct UnifiedExecRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for UnifiedExecRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, @@ -122,7 +111,6 @@ impl Approvable for UnifiedExecRuntime<'_> { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { session @@ -132,7 +120,6 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 5e69696923..96bc633c58 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,7 +7,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -20,7 +19,6 @@ use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; -use std::path::PathBuf; use futures::Future; use futures::future::BoxFuture; @@ -84,7 +82,6 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, - pub risk: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -219,17 +216,6 @@ pub(crate) struct ToolCtx<'a> { pub tool_name: String, } -/// Captures the command metadata needed to re-run a tool request without sandboxing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SandboxRetryData { - pub command: Vec, - pub cwd: PathBuf, -} - -pub(crate) trait ProvidesSandboxRetryData { - fn sandbox_retry_data(&self) -> Option; -} - #[derive(Debug)] pub(crate) enum ToolError { Rejected(String), diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md deleted file mode 100644 index dc59f47d7c..0000000000 --- a/codex-rs/core/templates/sandboxing/assessment_prompt.md +++ /dev/null @@ -1,24 +0,0 @@ -You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk to help the user decide whether to approve command execution. Return strictly valid JSON with the keys: -- description (concise summary of command intent and potential effects, no more than one sentence, use present tense) -- risk_level ("low", "medium", or "high") -Risk level examples: -- low: read-only inspections, listing files, printing configuration, fetching artifacts from trusted sources -- medium: modifying project files, installing dependencies -- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls -If information is insufficient, choose the most cautious risk level supported by the evidence. -Respond with JSON only, without markdown code fences or extra commentary. - ---- - -Command metadata: -Platform: {{ platform }} -Sandbox policy: {{ sandbox_policy }} -{% if let Some(roots) = filesystem_roots %} -Filesystem roots: {{ roots }} -{% endif %} -Working directory: {{ working_directory }} -Command argv: {{ command_argv }} -Command (joined): {{ command_joined }} -{% if let Some(message) = sandbox_failure_message %} -Sandbox failure message: {{ message }} -{% endif %} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0cf5aaf788..7dfeeecf7b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -200,7 +200,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: add_dir, }; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 4e61bde02b..feadf0add6 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -169,7 +169,6 @@ impl CodexToolCallParam { include_apply_patch_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 908cba1cc3..d39a38cde9 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -179,7 +179,6 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, - risk, proposed_execpolicy_amendment: _, parsed_cmd, }) => { @@ -193,7 +192,6 @@ async fn run_codex_tool_session_inner( event.id.clone(), call_id, parsed_cmd, - risk, ) .await; continue; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 033523ac0d..44607b754d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; @@ -38,8 +37,6 @@ pub struct ExecApprovalElicitRequestParams { pub codex_command: Vec, pub codex_cwd: PathBuf, pub codex_parsed_cmd: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub codex_risk: Option, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -62,7 +59,6 @@ pub(crate) async fn handle_exec_approval_request( event_id: String, call_id: String, codex_parsed_cmd: Vec, - codex_risk: Option, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -85,7 +81,6 @@ pub(crate) async fn handle_exec_approval_request( codex_command: command, codex_cwd: cwd, codex_parsed_cmd, - codex_risk, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index f65495c473..d0a78ae392 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -200,7 +200,6 @@ fn create_expected_elicitation_request( codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), codex_parsed_cmd, - codex_risk: None, })?), }) } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index d3536cd8db..54e3fe3dc1 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,7 +8,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; @@ -374,52 +373,6 @@ impl OtelEventManager { ); } - pub fn sandbox_assessment( - &self, - call_id: &str, - status: &str, - risk_level: Option, - duration: Duration, - ) { - let level = risk_level.map(|level| level.as_str()); - - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - status = %status, - risk_level = level, - duration_ms = %duration.as_millis(), - ); - } - - pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) { - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment_latency", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - duration_ms = %duration.as_millis(), - ); - } - pub async fn log_tool_result( &self, tool_name: &str, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index c892b6ec99..78050dfa86 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -9,14 +9,6 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum SandboxRiskLevel { - Low, - Medium, - High, -} - /// Proposed execpolicy change to allow commands starting with this prefix. /// /// The `command` tokens form the prefix that would be added as an execpolicy @@ -45,22 +37,6 @@ impl From> for ExecPolicyAmendment { } } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: SandboxRiskLevel, -} - -impl SandboxRiskLevel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -76,9 +52,6 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - #[serde(skip_serializing_if = "Option::is_none")] - pub risk: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1e40618f01..973fd26582 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -40,8 +40,6 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; -pub use crate::approvals::SandboxCommandAssessment; -pub use crate::approvals::SandboxRiskLevel; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 768fe030d4..d42861eb1d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -23,8 +23,6 @@ use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -45,7 +43,6 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -345,18 +342,11 @@ impl From for ApprovalRequestState { id, command, reason, - risk, proposed_execpolicy_amendment, } => { - let reason = reason.filter(|item| !item.is_empty()); - let has_reason = reason.is_some(); let mut header: Vec> = Vec::new(); if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - } - if let Some(risk) = risk.as_ref() { - header.extend(render_risk_lines(risk)); - } else if has_reason { header.push(Line::from("")); } let full_cmd = strip_bash_lc_and_escape(&command); @@ -419,28 +409,6 @@ impl From for ApprovalRequestState { } } -fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { - let level_span = match risk.risk_level { - SandboxRiskLevel::Low => "LOW".green().bold(), - SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(), - SandboxRiskLevel::High => "HIGH".red().bold(), - }; - - let mut lines = Vec::new(); - - let description = risk.description.trim(); - if !description.is_empty() { - lines.push(Line::from(vec![ - "Summary: ".into(), - description.to_string().into(), - ])); - } - - lines.push(vec!["Risk: ".into(), level_span].into()); - lines.push(Line::from("")); - lines -} - #[derive(Clone)] enum ApprovalVariant { Exec { @@ -570,7 +538,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), - risk: None, proposed_execpolicy_amendment: None, } } @@ -613,7 +580,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -652,7 +618,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -679,7 +644,6 @@ mod tests { id: "test".into(), command, reason: None, - risk: None, proposed_execpolicy_amendment: None, }; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8a4336f6fe..554810de7f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,7 +570,6 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, - risk: None, proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd623ed583..f9e53c8055 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1095,7 +1095,6 @@ impl ChatWidget { id, command: ev.command, reason: ev.reason, - risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index ca093f271a..15511611a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend().vt100().screen().contents() --- + + Would you like to run the following command? Reason: this is a test reason such as one that would be produced by the model diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap index 1b18a23d4d..2f0f1412a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -3,4 +3,3 @@ source: tui/src/chatwidget/tests.rs expression: lines_to_single_string(&decision) --- ✔ You approved codex to run echo hello world this time - diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fc1d25edfe..2355493250 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -755,7 +755,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -800,7 +799,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -851,7 +849,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2105,7 +2102,6 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2157,7 +2153,6 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2376,7 +2371,6 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0aa422cc61..d9793a07a0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -218,7 +218,6 @@ pub async fn run_main( include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; diff --git a/docs/config.md b/docs/config.md index 4e78da7ac6..8b131cd63a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,17 +39,16 @@ web_search_request = true # allow the model to request web searches Supported features: -| Key | Default | Stage | Description | -| ----------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | -| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | -| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | -| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | -| `view_image_tool` | true | Stable | Include the `view_image` tool | -| `web_search_request` | false | Stable | Allow the model to issue web searches | -| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | -| `ghost_commit` | false | Experimental | Create a ghost commit each turn | -| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | -| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | +| Key | Default | Stage | Description | +| ------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | Notes: diff --git a/docs/example-config.md b/docs/example-config.md index 1f326ac14b..2345274e1b 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -218,7 +218,6 @@ rmcp_client = false apply_patch_freeform = false view_image_tool = true web_search_request = false -experimental_sandbox_command_assessment = false ghost_commit = false enable_experimental_windows_sandbox = false @@ -314,7 +313,6 @@ experimental_use_freeform_apply_patch = false # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false # experimental_use_freeform_apply_patch = false -# experimental_sandbox_command_assessment = false # tools_web_search = false # tools_view_image = true # features = { unified_exec = false } From f677d05871b49628d62cd289e5fa2d1b5aa7af36 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:57:53 +0000 Subject: [PATCH 121/159] fix: flaky tests 3 (#7826) --- codex-rs/core/tests/suite/approvals.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 879ad56d47..10a510af42 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -126,7 +126,7 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; + let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { From bd51d1b10383f4cc28d5228c29e6726935800c7c Mon Sep 17 00:00:00 2001 From: Amit Halfon Date: Wed, 10 Dec 2025 20:17:00 +0200 Subject: [PATCH 122/159] fix: Upgrade @modelcontextprotocol/sdk to ^1.24.0 (#7817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Upgrades @modelcontextprotocol/sdk from ^1.20.2 to ^1.24.0 in the TypeScript SDK's devDependencies. ## Why? Related to #7737 - keeping development dependencies up to date with the latest MCP SDK version that includes the fix for CVE-2025-66414. Note: This change does not address the CVE for Codex users, as the MCP SDK is only in devDependencies here. The actual MCP integration that would be affected by the CVE is in the Rust codebase. ## How? • Updated dependency version in sdk/typescript/package.json • Ran pnpm install to update lockfile • Fixed formatting (added missing newline in package.json) ## Related Issue Related to #7737 ## Test Status ⚠️ After this upgrade, 2 additional tests timeout (1 test was already failing on main): • tests/run.test.ts: "sends previous items when run is called twice" • tests/run.test.ts: "resumes thread by id" • tests/runStreamed.test.ts: "sends previous items when runStreamed is called twice" Marking as draft to investigate test timeouts. Maintainer guidance would be appreciated. Co-authored-by: HalfonA --- pnpm-lock.yaml | 74 +++++++++++++++++++++++++++++++++---- sdk/typescript/package.json | 2 +- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d88bce45..01112d6d3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: sdk/typescript: devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.20.2 - version: 1.20.2 + specifier: ^1.24.0 + version: 1.24.3(zod@3.25.76) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -573,9 +573,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.20.2': - resolution: {integrity: sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==} + '@modelcontextprotocol/sdk@1.24.3': + resolution: {integrity: sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==} engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -846,9 +852,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1294,6 +1311,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1677,6 +1697,9 @@ packages: node-notifier: optional: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1706,6 +1729,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2053,6 +2079,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -2476,6 +2506,11 @@ packages: peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3012,9 +3047,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.20.2': + '@modelcontextprotocol/sdk@1.24.3(zod@3.25.76)': dependencies: - ajv: 6.12.6 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 @@ -3022,10 +3058,11 @@ snapshots: eventsource-parser: 3.0.6 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) + jose: 6.1.3 pkce-challenge: 5.0.0 raw-body: 3.0.1 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -3292,6 +3329,10 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3299,6 +3340,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3795,6 +3843,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -4367,6 +4417,8 @@ snapshots: - supports-color - ts-node + jose@6.1.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -4388,6 +4440,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4670,6 +4724,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -5116,4 +5172,8 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.0(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index b5a6ce82c3..55ecd1abf3 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -45,7 +45,7 @@ "prepare": "pnpm run build" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.20.2", + "@modelcontextprotocol/sdk": "^1.24.0", "@types/jest": "^29.5.14", "@types/node": "^20.19.18", "eslint": "^9.36.0", From 9f40d6eeebebbee316bca6eac8e7f312a9c1d0b8 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Thu, 11 Dec 2025 03:23:01 +0900 Subject: [PATCH 123/159] fix: remove duplicated parallel FeatureSpec (#7823) regression: #7589 Signed-off-by: Koichi Shiraishi --- codex-rs/core/src/features.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4b8370039a..a011884fc4 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -339,12 +339,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::ParallelToolCalls, - key: "parallel", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::Skills, key: "skills", From 4b684c53aea9a4a0573a7ffd3530eac71a670a04 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 10 Dec 2025 10:44:12 -0800 Subject: [PATCH 124/159] Remove conversation_id and bring back request ID logging (#7830) --- codex-rs/Cargo.lock | 1 + codex-rs/codex-client/src/default_client.rs | 143 ++++++++++++++++++++ codex-rs/codex-client/src/lib.rs | 3 + codex-rs/codex-client/src/transport.rs | 10 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/auth.rs | 2 +- codex-rs/core/src/default_client.rs | 134 +----------------- 7 files changed, 159 insertions(+), 135 deletions(-) create mode 100644 codex-rs/codex-client/src/default_client.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8ee790f676..df26fcfc93 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1098,6 +1098,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-async-utils", + "codex-client", "codex-core", "codex-execpolicy", "codex-file-search", diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs new file mode 100644 index 0000000000..8a25846385 --- /dev/null +++ b/codex-rs/codex-client/src/default_client.rs @@ -0,0 +1,143 @@ +use http::Error as HttpError; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Display; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + pub fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn headers(self, headers: HeaderMap) -> Self { + self.map(|builder| builder.headers(headers)) + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn timeout(self, timeout: Duration) -> Self { + self.map(|builder| builder.timeout(timeout)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub async fn send(self) -> Result { + match self.builder.send().await { + Ok(response) => { + let request_ids = Self::extract_request_ids(&response); + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + request_ids = ?request_ids, + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } + + fn extract_request_ids(response: &Response) -> HashMap { + ["cf-ray", "x-request-id", "x-oai-request-id"] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } +} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 3ac00a21a8..66d1083c07 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,3 +1,4 @@ +mod default_client; mod error; mod request; mod retry; @@ -5,6 +6,8 @@ mod sse; mod telemetry; mod transport; +pub use crate::default_client::CodexHttpClient; +pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::request::Request; diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index 5edc9a7b77..986ba3a679 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -1,3 +1,5 @@ +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::Response; @@ -28,15 +30,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: reqwest::Client, + client: CodexHttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { - Self { client } + Self { + client: CodexHttpClient::new(client), + } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let mut builder = self .client .request( diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a11e7c24d6..4c231e4dda 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -23,6 +23,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-client = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 57ffa17260..20943982d4 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -23,7 +23,6 @@ pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; -use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; use crate::token_data::KnownPlan as InternalKnownPlan; @@ -31,6 +30,7 @@ use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_client::CodexHttpClient; use codex_protocol::account::PlanType as AccountPlanType; use once_cell::sync::Lazy; use serde_json::Value; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 29986c401d..7ae2f8c35a 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,17 +1,12 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; -use http::Error as HttpError; -use reqwest::IntoUrl; -use reqwest::Method; -use reqwest::Response; -use reqwest::header::HeaderName; use reqwest::header::HeaderValue; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt::Display; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -31,129 +26,6 @@ pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; -#[derive(Clone, Debug)] -pub struct CodexHttpClient { - inner: reqwest::Client, -} - -impl CodexHttpClient { - fn new(inner: reqwest::Client) -> Self { - Self { inner } - } - - pub fn get(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::GET, url) - } - - pub fn post(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::POST, url) - } - - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) - } -} - -#[must_use = "requests are not sent unless `send` is awaited"] -#[derive(Debug)] -pub struct CodexRequestBuilder { - builder: reqwest::RequestBuilder, - method: Method, - url: String, -} - -impl CodexRequestBuilder { - fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { - Self { - builder, - method, - url, - } - } - - fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { - Self { - builder: f(self.builder), - method: self.method, - url: self.url, - } - } - - pub fn header(self, key: K, value: V) -> Self - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - self.map(|builder| builder.header(key, value)) - } - - pub fn bearer_auth(self, token: T) -> Self - where - T: Display, - { - self.map(|builder| builder.bearer_auth(token)) - } - - pub fn json(self, value: &T) -> Self - where - T: ?Sized + Serialize, - { - self.map(|builder| builder.json(value)) - } - - pub async fn send(self) -> Result { - match self.builder.send().await { - Ok(response) => { - let request_ids = Self::extract_request_ids(&response); - tracing::debug!( - method = %self.method, - url = %self.url, - status = %response.status(), - request_ids = ?request_ids, - version = ?response.version(), - "Request completed" - ); - - Ok(response) - } - Err(error) => { - let status = error.status(); - tracing::debug!( - method = %self.method, - url = %self.url, - status = status.map(|s| s.as_u16()), - error = %error, - "Request failed" - ); - Err(error) - } - } - } - - fn extract_request_ids(response: &Response) -> HashMap { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .filter_map(|&name| { - let header_name = HeaderName::from_static(name); - let value = response.headers().get(header_name)?; - let value = value.to_str().ok()?.to_owned(); - Some((name.to_owned(), value)) - }) - .collect() - } -} #[derive(Debug, Clone)] pub struct Originator { pub value: String, From 8a71f8b6348a4ff48a403615674b17fb890b320e Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 10 Dec 2025 11:14:27 -0800 Subject: [PATCH 125/159] [app-server] Make sure that config writes preserve comments & order or configs (#7789) Make sure that config writes preserve comments and order of configs by utilizing the ConfigEditsBuilder in core. Tested by running a real example and made sure that nothing in the config file changes other than the configs to edit. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/config_api.rs | 190 ++++++++++++++++++++++---- codex-rs/core/src/config/edit.rs | 26 ++++ 4 files changed, 189 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df26fcfc93..304343bf2c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -851,6 +851,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "tracing", "tracing-subscriber", "uuid", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index e4a326a2c3..948facdea6 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -35,6 +35,7 @@ sha2 = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ae02927f7a..98fe93fb25 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,6 +1,5 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use anyhow::anyhow; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -15,6 +14,8 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_core::config::ConfigToml; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::LoadedConfigLayers; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::load_config_layers_with_overrides; @@ -26,9 +27,8 @@ use sha2::Sha256; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; -use tokio::task; use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; const SESSION_FLAGS_SOURCE: &str = "--config"; const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; @@ -141,19 +141,20 @@ impl ConfigApi { } let mut user_config = layers.user.config.clone(); - let mut mutated = false; let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); for (key_path, value, strategy) in edits.into_iter() { let segments = parse_key_path(&key_path).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; + let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; - let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy) - .map_err(|err| match err { + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { MergeError::PathNotFound => config_write_error( ConfigWriteErrorCode::ConfigPathNotFound, "Path not found", @@ -161,9 +162,24 @@ impl ConfigApi { MergeError::Validation(message) => { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) } - })?; + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value) + .map_err(|err| internal_error("failed to build config edits", err))?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } - mutated |= changed; parsed_segments.push(segments); } @@ -183,8 +199,10 @@ impl ConfigApi { ) })?; - if mutated { - self.persist_user_config(&user_config) + if !config_edits.is_empty() { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() .await .map_err(|err| internal_error("failed to persist config.toml", err))?; } @@ -253,25 +271,6 @@ impl ConfigApi { mdm, }) } - - async fn persist_user_config(&self, user_config: &TomlValue) -> anyhow::Result<()> { - let codex_home = self.codex_home.clone(); - let serialized = toml::to_string_pretty(user_config)?; - - task::spawn_blocking(move || -> anyhow::Result<()> { - std::fs::create_dir_all(&codex_home)?; - - let target = codex_home.join(CONFIG_FILE_NAME); - let tmp = NamedTempFile::new_in(&codex_home)?; - std::fs::write(tmp.path(), serialized.as_bytes())?; - tmp.persist(&target)?; - Ok(()) - }) - .await - .map_err(|err| anyhow!("config persistence task panicked: {err}"))??; - - Ok(()) - } } fn parse_value(value: JsonValue) -> Result, String> { @@ -422,6 +421,44 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + #[derive(Clone)] struct LayerState { name: ConfigLayerName, @@ -735,9 +772,104 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; + use anyhow::Result; use pretty_assertions::assert_eq; use tempfile::tempdir; + #[test] + fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); + } + + #[tokio::test] + async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original)?; + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), + key_path: "features.remote_compaction".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +remote_compaction = true +"#; + assert_eq!(updated, expected); + Ok(()) + } + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 68e2d206f0..37c2aba6ef 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -555,6 +555,14 @@ impl ConfigEditsBuilder { self } + pub fn with_edits(mut self, edits: I) -> Self + where + I: IntoIterator, + { + self.edits.extend(edits); + self + } + /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) @@ -603,6 +611,24 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } + #[test] + fn builder_with_edits_applies_custom_paths() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits(vec![ConfigEdit::SetPath { + segments: vec!["enabled".to_string()], + value: value(true), + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "enabled = true\n"); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); From cb9a189857c881b04520f8636492795a473eaf7d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 11:19:00 -0800 Subject: [PATCH 126/159] make `model` optional in config (#7769) - Make Config.model optional and centralize default-selection logic in ModelsManager, including a default_model helper (with codex-auto-balanced when available) so sessions now carry an explicit chosen model separate from the base config. - Resolve `model` once in `core` and `tui` from config. Then store the state of it on other structs. - Move refreshing models to be before resolving the default model --- .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/app-server/src/models.rs | 8 +- codex-rs/app-server/tests/common/lib.rs | 3 + .../app-server/tests/common/models_cache.rs | 74 ++++++++++ .../app-server/tests/suite/v2/model_list.rs | 4 + codex-rs/common/src/config_summary.rs | 4 +- codex-rs/core/src/client.rs | 8 +- codex-rs/core/src/codex.rs | 62 ++++----- codex-rs/core/src/config/mod.rs | 21 +-- codex-rs/core/src/conversation_manager.rs | 19 ++- codex-rs/core/src/model_provider_info.rs | 80 +++++------ .../core/src/openai_models/model_family.rs | 4 + .../core/src/openai_models/models_manager.rs | 106 +++++++++++++-- codex-rs/core/src/tasks/review.rs | 2 + .../core/tests/chat_completions_payload.rs | 21 ++- codex-rs/core/tests/chat_completions_sse.rs | 5 +- codex-rs/core/tests/common/responses.rs | 31 +++++ codex-rs/core/tests/common/test_codex.rs | 14 +- codex-rs/core/tests/responses_headers.rs | 19 ++- codex-rs/core/tests/suite/client.rs | 126 +++++++++++------- codex-rs/core/tests/suite/compact.rs | 82 ++++++++---- .../core/tests/suite/compact_resume_fork.rs | 24 ++-- .../core/tests/suite/fork_conversation.rs | 5 +- codex-rs/core/tests/suite/list_models.rs | 22 ++- codex-rs/core/tests/suite/model_overrides.rs | 14 +- codex-rs/core/tests/suite/prompt_caching.rs | 29 +++- codex-rs/core/tests/suite/remote_models.rs | 91 ++++++++++--- codex-rs/core/tests/suite/resume_warning.rs | 14 +- codex-rs/core/tests/suite/review.rs | 27 ++-- codex-rs/core/tests/suite/rmcp_client.rs | 8 +- codex-rs/core/tests/suite/unified_exec.rs | 51 ++----- codex-rs/core/tests/suite/user_shell_cmd.rs | 12 +- .../src/event_processor_with_human_output.rs | 3 +- codex-rs/exec/src/lib.rs | 5 +- codex-rs/lmstudio/src/lib.rs | 5 +- codex-rs/ollama/src/lib.rs | 5 +- codex-rs/tui/src/app.rs | 52 +++++--- codex-rs/tui/src/app_backtrack.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 23 ++-- codex-rs/tui/src/chatwidget/tests.rs | 52 ++++---- codex-rs/tui/src/history_cell.rs | 35 ++--- codex-rs/tui/src/status/card.rs | 7 +- codex-rs/tui/src/status/helpers.rs | 4 +- codex-rs/tui/src/status/tests.rs | 78 +++++++---- 44 files changed, 838 insertions(+), 429 deletions(-) create mode 100644 codex-rs/app-server/tests/common/models_cache.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8576c5c381..7876cccf89 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1885,7 +1885,7 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let models = supported_models(self.conversation_manager.clone()).await; + let models = supported_models(self.conversation_manager.clone(), &self.config).await; let total = models.len(); if total == 0 { @@ -2796,7 +2796,7 @@ impl CodexMessageProcessor { })?; let mut config = self.config.as_ref().clone(); - config.model = self.config.review_model.clone(); + config.model = Some(self.config.review_model.clone()); let NewConversation { conversation_id, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 3ac71e85b9..2141160354 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,12 +3,16 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ConversationManager; +use codex_core::config::Config; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(conversation_manager: Arc) -> Vec { +pub async fn supported_models( + conversation_manager: Arc, + config: &Config, +) -> Vec { conversation_manager - .list_models() + .list_models(config) .await .into_iter() .map(model_from_preset) diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6fd54a66dc..825b063c98 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,6 +1,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; +mod models_cache; mod responses; mod rollout; @@ -14,6 +15,8 @@ pub use core_test_support::format_with_current_shell_display; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; pub use mock_model_server::create_mock_chat_completions_server_unchecked; +pub use models_cache::write_models_cache; +pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs new file mode 100644 index 0000000000..8306e34394 --- /dev/null +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -0,0 +1,74 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_core::openai_models::model_presets::all_model_presets; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use serde_json::json; +use std::path::Path; + +/// Convert a ModelPreset to ModelInfo for cache storage. +fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { + ModelInfo { + slug: preset.id.clone(), + display_name: preset.display_name.clone(), + description: Some(preset.description.clone()), + default_reasoning_level: preset.default_reasoning_effort, + supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: if preset.show_in_picker { + ModelVisibility::List + } else { + ModelVisibility::Hide + }, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority, + upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()), + base_instructions: None, + } +} + +/// Write a models_cache.json file to the codex home directory. +/// This prevents ModelsManager from making network requests to refresh models. +/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network. +/// Uses the built-in model presets from ModelsManager, converted to ModelInfo format. +pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> { + // Get all presets and filter for show_in_picker (same as builtin_model_presets does) + let presets: Vec<&ModelPreset> = all_model_presets() + .iter() + .filter(|preset| preset.show_in_picker) + .collect(); + // Convert presets to ModelInfo, assigning priorities (higher = earlier in list) + // Priority is used for sorting, so first model gets highest priority + let models: Vec = presets + .iter() + .enumerate() + .map(|(idx, preset)| { + // Higher priority = earlier in list, so reverse the index + let priority = (presets.len() - idx) as i32; + preset_to_info(preset, priority) + }) + .collect(); + + write_models_cache_with_models(codex_home, models) +} + +/// Write a models_cache.json file with specific models. +/// Useful when tests need specific models to be available. +pub fn write_models_cache_with_models( + codex_home: &Path, + models: Vec, +) -> std::io::Result<()> { + let cache_path = codex_home.join("models_cache.json"); + // DateTime serializes to RFC3339 format by default with serde + let fetched_at: DateTime = Utc::now(); + let cache = json!({ + "fetched_at": fetched_at, + "etag": null, + "models": models + }); + std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?) +} diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 8ca85c9c3b..0e0f607e26 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -4,6 +4,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_models_cache; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; @@ -22,6 +23,7 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -151,6 +153,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -248,6 +251,7 @@ async fn list_models_pagination_works() -> Result<()> { #[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 32b837f1f5..5a5901880f 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -4,10 +4,10 @@ use codex_core::config::Config; use crate::sandbox_summary::summarize_sandbox_policy; /// Build a list of key/value pairs summarizing the effective configuration. -pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { +pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'static str, String)> { let mut entries = vec![ ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), + ("model", model.to_string()), ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d4a714cdd5..72c23a3ea4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -166,7 +166,7 @@ impl ModelClient { let stream_result = client .stream_prompt( - &self.config.model, + &self.get_model(), &api_prompt, Some(conversation_id.clone()), Some(session_source.clone()), @@ -260,7 +260,7 @@ impl ModelClient { }; let stream_result = client - .stream_prompt(&self.config.model, &api_prompt, options) + .stream_prompt(&self.get_model(), &api_prompt, options) .await; match stream_result { @@ -292,7 +292,7 @@ impl ModelClient { /// Returns the currently configured model slug. pub fn get_model(&self) -> String { - self.config.model.clone() + self.get_model_family().get_model_slug().to_string() } /// Returns the currently configured model family. @@ -337,7 +337,7 @@ impl ModelClient { .get_full_instructions(&self.get_model_family()) .into_owned(); let payload = ApiCompactionInput { - model: &self.config.model, + model: &self.get_model(), input: &prompt.input, instructions: &instructions, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6f637d143c..22570ad1b3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -181,10 +181,15 @@ impl Codex { let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); - + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = models_manager.refresh_available_models(&config).await + { + error!("failed to refresh available models: {err:?}"); + } + let model = models_manager.get_model(&config.model, &config).await; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model: model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -398,10 +403,11 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.features = config.features.clone(); @@ -421,7 +427,7 @@ impl Session { ) -> TurnContext { let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - model_family.slug.as_str(), + model_family.get_model_slug(), ); let per_turn_config = Arc::new(per_turn_config); @@ -544,14 +550,11 @@ impl Session { }); } - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), - model_family.slug.as_str(), + session_configuration.model.as_str(), + session_configuration.model.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), @@ -780,7 +783,7 @@ impl Session { let model_family = self .services .models_manager - .construct_model_family(&per_turn_config.model, &per_turn_config) + .construct_model_family(session_configuration.model.as_str(), &per_turn_config) .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -1444,16 +1447,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let mut previous_context: Option> = Some(sess.new_turn(SessionSettingsUpdate::default()).await); - if config.features.enabled(Feature::RemoteModels) - && let Err(err) = sess - .services - .models_manager - .refresh_available_models(&config.model_provider) - .await - { - error!("failed to refresh available models: {err}"); - } - // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -1925,7 +1918,6 @@ async fn spawn_review_thread( // Build per‑turn client with the requested model/family. let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); @@ -1934,7 +1926,7 @@ async fn spawn_review_thread( .client .get_otel_event_manager() .with_model( - per_turn_config.model.as_str(), + config.review_model.as_str(), review_model_family.slug.as_str(), ); @@ -2555,9 +2547,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2626,9 +2619,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2803,7 +2797,7 @@ mod tests { ) -> OtelEventManager { OtelEventManager::new( conversation_id, - config.model.as_str(), + ModelsManager::get_model_offline(config.model.as_deref()).as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -2827,9 +2821,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2844,8 +2839,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); @@ -2909,9 +2906,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2926,8 +2924,10 @@ mod tests { session_source: SessionSource::Exec, }; let per_turn_config = Session::build_per_turn_config(&session_configuration); - let model_family = - ModelsManager::construct_model_family_offline(&per_turn_config.model, &per_turn_config); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref(), &model_family); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bdf7a54177..e0e6985a39 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -59,7 +59,6 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -73,7 +72,7 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; #[derive(Debug, Clone, PartialEq)] pub struct Config { /// Optional override of model selection. - pub model: String, + pub model: Option, /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, @@ -1108,11 +1107,7 @@ impl Config { let forced_login_method = cfg.forced_login_method; - // todo(aibrahim): make model optional - let model = model - .or(config_profile.model) - .or(cfg.model) - .unwrap_or_else(default_model); + let model = model.or(config_profile.model).or(cfg.model); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -1313,10 +1308,6 @@ impl Config { } } -fn default_model() -> String { - OPENAI_DEFAULT_MODEL.to_string() -} - fn default_review_model() -> String { OPENAI_DEFAULT_REVIEW_MODEL.to_string() } @@ -2940,7 +2931,7 @@ model_verbosity = "high" )?; assert_eq!( Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3014,7 +3005,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { - model: "gpt-3.5-turbo".to_string(), + model: Some("gpt-3.5-turbo".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3103,7 +3094,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_zdr_profile_config = Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, @@ -3178,7 +3169,7 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt5_profile_config = Config { - model: "gpt-5.1".to_string(), + model: Some("gpt-5.1".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_context_window: None, model_auto_compact_token_limit: None, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index b1818849eb..f340e1a833 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,5 +1,7 @@ use crate::AuthManager; use crate::CodexAuth; +#[cfg(any(test, feature = "test-support"))] +use crate::ModelProviderInfo; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; @@ -54,11 +56,14 @@ impl ConversationManager { #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. - pub fn with_auth(auth: CodexAuth) -> Self { - Self::new( - crate::AuthManager::from_auth_for_testing(auth), - SessionSource::Exec, - ) + pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + Self { + conversations: Arc::new(RwLock::new(HashMap::new())), + auth_manager: auth_manager.clone(), + session_source: SessionSource::Exec, + models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + } } pub fn session_source(&self) -> SessionSource { @@ -213,8 +218,8 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } - pub async fn list_models(&self) -> Vec { - self.models_manager.list_models().await + pub async fn list_models(&self, config: &Config) -> Vec { + self.models_manager.list_models(config).await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 4912a64694..82072fc2aa 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -208,6 +208,45 @@ impl ModelProviderInfo { .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } + pub fn create_openai_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: true, + } + } } pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; @@ -225,46 +264,7 @@ pub fn built_in_model_providers() -> HashMap { // open source ("oss") providers by default. Users are encouraged to add to // `model_providers` in config.toml to add their own providers. [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ( - "OpenAI-Organization".to_string(), - "OPENAI_ORGANIZATION".to_string(), - ), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: true, - }, - ), + ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 8a3853d60b..2cc6fd0844 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -116,6 +116,10 @@ impl ModelFamily { const fn default_auto_compact_limit(context_window: i64) -> i64 { (context_window * 9) / 10 } + + pub fn get_model_slug(&self) -> &str { + &self.slug + } } macro_rules! model_family { diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 03dbd39d3d..de9aa0f7c8 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -21,6 +21,7 @@ use crate::auth::AuthManager; use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::Result as CoreResult; +use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; @@ -28,6 +29,8 @@ use crate::openai_models::model_presets::builtin_model_presets; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); +const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; /// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] @@ -39,6 +42,7 @@ pub struct ModelsManager { etag: RwLock>, codex_home: PathBuf, cache_ttl: Duration, + provider: ModelProviderInfo, } impl ModelsManager { @@ -52,18 +56,37 @@ impl ModelsManager { etag: RwLock::new(None), codex_home, cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider: ModelProviderInfo::create_openai_provider(), + } + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests. + pub fn with_provider(auth_manager: Arc, provider: ModelProviderInfo) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); + Self { + available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + remote_models: RwLock::new(Vec::new()), + auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider, } } /// Fetch the latest remote models, using the on-disk cache when still fresh. - pub async fn refresh_available_models(&self, provider: &ModelProviderInfo) -> CoreResult<()> { + pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> { + if !config.features.enabled(Feature::RemoteModels) { + return Ok(()); + } if self.try_load_cache().await { return Ok(()); } let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(Some(AuthMode::ChatGPT))?; - let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; + let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); @@ -81,7 +104,10 @@ impl ModelsManager { Ok(()) } - pub async fn list_models(&self) -> Vec { + pub async fn list_models(&self, config: &Config) -> Vec { + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } self.available_models.read().await.clone() } @@ -98,6 +124,33 @@ impl ModelsManager { .with_remote_overrides(self.remote_models.read().await.clone()) } + pub async fn get_model(&self, model: &Option, config: &Config) -> String { + if let Some(model) = model.as_ref() { + return model.to_string(); + } + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } + // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model + let auth_mode = self.auth_manager.get_auth_mode(); + if auth_mode == Some(AuthMode::ChatGPT) + && self + .available_models + .read() + .await + .iter() + .any(|m| m.model == CODEX_AUTO_BALANCED_MODEL) + { + return CODEX_AUTO_BALANCED_MODEL.to_string(); + } + OPENAI_DEFAULT_MODEL.to_string() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_model_offline(model: Option<&str>) -> String { + model.unwrap_or(OPENAI_DEFAULT_MODEL).to_string() + } + #[cfg(any(test, feature = "test-support"))] /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { @@ -112,6 +165,7 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + // todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk let cache_path = self.cache_path(); let cache = match cache::load_cache(&cache_path).await { Ok(cache) => cache, @@ -197,6 +251,10 @@ mod tests { use super::*; use crate::CodexAuth; use crate::auth::AuthCredentialsStoreMode; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::features::Feature; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; @@ -256,19 +314,27 @@ mod tests { ) .await; + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("refresh succeeds"); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.list_models().await; + let available = manager.list_models(&config).await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -298,16 +364,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("first refresh succeeds"); assert_eq!( @@ -318,7 +391,7 @@ mod tests { // Second call should read from cache and avoid the network. manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("cached refresh succeeds"); assert_eq!( @@ -347,16 +420,23 @@ mod tests { .await; let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = Arc::new(AuthManager::new( codex_home.path().to_path_buf(), false, AuthCredentialsStoreMode::File, )); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("initial refresh succeeds"); @@ -382,7 +462,7 @@ mod tests { .await; manager - .refresh_available_models(&provider) + .refresh_available_models(&config) .await .expect("second refresh succeeds"); assert_eq!( diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 5c2e8d08b9..da7f29d4ad 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -92,6 +92,8 @@ async fn start_review_conversation( // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); + + sub_agent_config.model = Some(config.review_model.clone()); (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 1449a833da..6bfad43783 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use std::sync::Arc; use codex_app_server_protocol::AuthMode; @@ -71,10 +73,11 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -108,11 +111,15 @@ async fn run_request(input: Vec) -> Value { } } - let requests = match server.received_requests().await { - Some(reqs) => reqs, - None => panic!("request not made"), - }; - match requests[0].body_json() { + let all_requests = server.received_requests().await.expect("received requests"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); + let request = requests + .first() + .unwrap_or_else(|| panic!("expected POST request to /chat/completions")); + match request.body_json() { Ok(v) => v, Err(e) => panic!("invalid json body: {e}"), } diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index fe7ec58945..9124d59d13 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -74,10 +74,11 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index c67daeda87..b98b29625e 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -689,6 +689,33 @@ pub async fn start_mock_server() -> MockServer { server } +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get all POST requests to `/responses` endpoints from the mock server. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_requests(server: &MockServer) -> Vec { + server + .received_requests() + .await + .expect("mock server should not fail") + .into_iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/responses")) + .collect() +} + +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get request bodies as JSON values from POST requests to `/responses` endpoints. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_request_bodies(server: &MockServer) -> Vec { + get_responses_requests(server) + .await + .into_iter() + .map(|req| { + req.body_json::() + .expect("request body to be valid JSON") + }) + .collect() +} + #[derive(Clone)] pub struct FunctionCallResponseMocks { pub function_call: ResponseMock, @@ -769,6 +796,10 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res /// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` /// in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { + // Skip GET requests (e.g., /models) + if request.method != "POST" || !request.url.path().ends_with("/responses") { + return; + } let Ok(body): Result = request.body_json() else { return; }; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 23bcadadf1..5f38dbd4b5 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,6 +23,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::get_responses_request_bodies; use crate::responses::start_mock_server; use crate::wait_for_event; @@ -69,7 +70,7 @@ impl TestCodexBuilder { pub fn with_model(self, model: &str) -> Self { let new_model = model.to_string(); self.with_config(move |config| { - config.model = new_model.clone(); + config.model = Some(new_model.clone()); }) } @@ -96,7 +97,8 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = ConversationManager::with_auth(auth.clone()); + let conversation_manager = + ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); let new_conversation = match resume_from { Some(path) => { @@ -272,13 +274,7 @@ impl TestCodexHarness { } pub async fn request_bodies(&self) -> Vec { - self.server - .received_requests() - .await - .expect("requests") - .into_iter() - .map(|req| serde_json::from_slice(&req.body).expect("request body json")) - .collect() + get_responses_request_bodies(&self.server).await } pub async fn function_call_output_value(&self, call_id: &str) -> Value { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index d79de72167..934c327a6c 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -61,14 +61,16 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -151,15 +153,17 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -235,7 +239,7 @@ async fn responses_respects_model_family_overrides_from_config() { let codex_home = TempDir::new().expect("failed to create TempDir"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); @@ -243,15 +247,16 @@ async fn responses_respects_model_family_overrides_from_config() { config.model_reasoning_summary = ReasoningSummary::Detailed; let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = config.model.clone().expect("model configured"); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8b3d63a414..faa9801f86 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -30,7 +30,12 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; -use core_test_support::responses; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::get_responses_requests; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -240,7 +245,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Mock server that will receive the resumed request let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; // Configure Codex to resume from our file let model_provider = ModelProviderInfo { @@ -253,8 +258,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let NewConversation { @@ -337,8 +344,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -360,7 +369,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -381,7 +393,7 @@ async fn includes_base_instructions_override_in_request() { skip_if_no_network!(); // Mock server let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -393,8 +405,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -451,7 +465,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -473,7 +490,10 @@ async fn chatgpt_auth_sends_correct_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -569,7 +589,7 @@ async fn includes_user_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -581,8 +601,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -627,7 +649,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -648,8 +670,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -695,7 +719,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -734,7 +758,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .build(&server) @@ -771,7 +795,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_fami skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -804,7 +828,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -837,7 +861,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -875,7 +899,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1") .with_config(|config| { @@ -914,7 +938,7 @@ async fn includes_developer_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -927,8 +951,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1014,13 +1040,15 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -1103,11 +1131,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { } } - let requests = server - .received_requests() - .await - .expect("mock server collected requests"); - assert_eq!(requests.len(), 1, "expected a single request"); + let requests = get_responses_requests(&server).await; + assert_eq!(requests.len(), 1, "expected a single POST request"); let body: serde_json::Value = requests[0] .body_json() .expect("request body to be valid JSON"); @@ -1128,7 +1153,7 @@ async fn token_count_includes_rate_limits_snapshot() { skip_if_no_network!(); let server = MockServer::start().await; - let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + let sse_body = sse(vec![ev_completed_with_tokens("resp_rate", 123)]); let response = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") @@ -1154,7 +1179,10 @@ async fn token_count_includes_rate_limits_snapshot() { let mut config = load_default_config_for_test(&home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1361,10 +1389,10 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("trigger context window"), - responses::sse_failed( + sse_failed( "resp_context_window", "context_length_exceeded", "Your input exceeds the context window of this model. Please adjust your input and try again.", @@ -1372,7 +1400,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res ) .await; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("seed turn"), sse_completed("resp_seed"), @@ -1381,7 +1409,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.model = "gpt-5.1".to_string(); + config.model = Some("gpt-5.1".to_string()); config.model_context_window = Some(272_000); }) .build(&server) @@ -1505,7 +1533,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1583,7 +1614,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1661,8 +1695,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -1699,7 +1735,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Inspect the three captured requests. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)"); // Replace full-array compare with tail-only raw JSON compare using a single hard-coded value. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index aa74ec8978..521a76845a 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -28,6 +28,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::get_responses_requests; use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -135,7 +136,10 @@ async fn summarize_context_three_requests_and_instructions() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -329,7 +333,10 @@ async fn manual_compact_uses_custom_prompt() { config.model_provider = model_provider; config.compact_prompt = Some(custom_prompt.to_string()); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -344,7 +351,7 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("collect requests"); + let requests = get_responses_requests(&server).await; let body = requests .iter() .find_map(|req| req.body_json::().ok()) @@ -409,7 +416,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -570,7 +580,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // collect the requests payloads from the model - let requests_payloads = server.received_requests().await.unwrap(); + let requests_payloads = get_responses_requests(&server).await; let body = requests_payloads[0] .body_json::() @@ -1050,7 +1060,10 @@ async fn auto_compact_runs_after_token_limit_hit() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1090,7 +1103,7 @@ async fn auto_compact_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!( requests.len(), 5, @@ -1295,7 +1308,10 @@ async fn auto_compact_persists_rollout_entries() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -1397,11 +1413,14 @@ async fn manual_compact_retries_after_context_window_error() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1529,11 +1548,14 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1731,7 +1753,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1771,10 +1796,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "auto compact should not emit task lifecycle events" ); - let request_bodies: Vec = server - .received_requests() - .await - .unwrap() + let requests = get_responses_requests(&server).await; + let request_bodies: Vec = requests .into_iter() .map(|request| String::from_utf8(request.body).unwrap_or_default()) .collect(); @@ -1845,11 +1868,14 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f81294baf3..5d3d9e4b8a 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -26,6 +26,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::wait_for_event; @@ -771,17 +772,11 @@ fn normalize_line_endings(value: &mut Value) { } async fn gather_request_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .expect("mock server should not fail") - .into_iter() - .map(|req| { - let mut value = req.body_json::().expect("valid JSON body"); - normalize_line_endings(&mut value); - value - }) - .collect() + let mut bodies = get_responses_request_bodies(server).await; + for body in &mut bodies { + normalize_line_endings(body); + } + bodies } async fn mount_initial_flow(server: &MockServer) { @@ -870,9 +865,12 @@ async fn start_test_conversation( config.model_provider = model_provider; config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); if let Some(model) = model { - config.model = model.to_string(); + config.model = Some(model.to_string()); } - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation, .. } = manager .new_conversation(config.clone()) .await diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 75b37ae7ef..a82b476214 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -55,7 +55,10 @@ async fn fork_conversation_twice_drops_to_first_message() { config.model_provider = model_provider.clone(); let config_for_fork = config.clone(); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 6348841c6f..70df5174f3 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -1,15 +1,23 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::built_in_model_providers; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; +use tempfile::tempdir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_api_key_models() -> Result<()> { - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("sk-test"), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_api_key(); assert_eq!(expected_models, models); @@ -19,9 +27,13 @@ async fn list_models_returns_api_key_models() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let manager = - ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_chatgpt(); assert_eq!(expected_models, models); diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f67196312f..53a45e6786 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -20,10 +20,12 @@ async fn override_turn_context_does_not_persist_when_config_exists() { .expect("seed config.toml"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-4o".to_string(); + config.model = Some("gpt-4o".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -62,8 +64,10 @@ async fn override_turn_context_does_not_create_config_file() { let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2bc71298d4..94158df6d8 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -71,7 +71,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); - config.model = "codex-mini-latest".to_string(); + config.model = Some("codex-mini-latest".to_string()); }) .build(&server) .await?; @@ -131,12 +131,19 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.model = Some("gpt-5.1-codex-max".to_string()); }) .build(&server) .await?; let base_instructions = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family( + config + .model + .as_deref() + .expect("test config should have a model"), + &config, + ) .await .base_instructions .clone(); @@ -572,7 +579,12 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -582,7 +594,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -659,7 +671,12 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -669,7 +686,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 0f80407473..707ab6fa45 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -3,6 +3,12 @@ use std::sync::Arc; use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; +use codex_core::config::Config; use codex_core::features::Feature; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AskForApproval; @@ -20,6 +26,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::user_input::UserInput; +use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -30,11 +37,10 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use serde_json::json; +use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; @@ -80,21 +86,23 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - let available_model = wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG).await; + let available_model = + wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; assert_eq!(available_model.model, REMOTE_MODEL_SLUG); @@ -218,20 +226,22 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await; - let mut builder = test_codex().with_config(|config| { + let harness = build_remote_models_harness(&server, |config| { config.features.enable(Feature::RemoteModels); - config.model = "gpt-5.1".to_string(); - }); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; - let TestCodex { + let RemoteModelsHarness { codex, cwd, + config, conversation_manager, .. - } = builder.build(&server).await?; + } = harness; let models_manager = conversation_manager.get_models_manager(); - wait_for_model_available(&models_manager, model).await; + wait_for_model_available(&models_manager, model, &config).await; codex .submit(Op::OverrideTurnContext { @@ -268,11 +278,15 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { Ok(()) } -async fn wait_for_model_available(manager: &Arc, slug: &str) -> ModelPreset { +async fn wait_for_model_available( + manager: &Arc, + slug: &str, + config: &Config, +) -> ModelPreset { let deadline = Instant::now() + Duration::from_secs(2); loop { if let Some(model) = { - let guard = manager.list_models().await; + let guard = manager.list_models(config).await; guard.iter().find(|model| model.model == slug).cloned() } { return model; @@ -283,3 +297,48 @@ async fn wait_for_model_available(manager: &Arc, slug: &str) -> M sleep(Duration::from_millis(25)).await; } } + +struct RemoteModelsHarness { + codex: Arc, + cwd: Arc, + config: Config, + conversation_manager: Arc, +} + +// todo(aibrahim): move this to with_model_provier in test_codex +async fn build_remote_models_harness( + server: &MockServer, + mutate_config: F, +) -> Result +where + F: FnOnce(&mut Config), +{ + let auth = CodexAuth::from_api_key("dummy"); + let home = Arc::new(TempDir::new()?); + let cwd = Arc::new(TempDir::new()?); + + let mut config = load_default_config_for_test(&home); + config.cwd = cwd.path().to_path_buf(); + config.features.enable(Feature::RemoteModels); + + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + config.model_provider = provider.clone(); + + mutate_config(&mut config); + + let conversation_manager = Arc::new(ConversationManager::with_models_provider(auth, provider)); + + let new_conversation = conversation_manager + .new_conversation(config.clone()) + .await?; + + Ok(RemoteModelsHarness { + codex: new_conversation.conversation, + cwd, + config, + conversation_manager, + }) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index c8376e4109..cb83ab06dc 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -4,6 +4,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; @@ -16,7 +17,11 @@ use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use tempfile::TempDir; -fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { +fn resume_history( + config: &codex_core::config::Config, + previous_model: &str, + rollout_path: &std::path::Path, +) -> InitialHistory { let turn_ctx = TurnContextItem { cwd: config.cwd.clone(), approval_policy: config.approval_policy, @@ -38,7 +43,7 @@ async fn emits_warning_when_resumed_model_differs() { // Arrange a config with a current model and a prior rollout recorded under a different model. let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home); - config.model = "current-model".to_string(); + config.model = Some("current-model".to_string()); // Ensure cwd is absolute (the helper sets it to the temp dir already). assert!(config.cwd.is_absolute()); @@ -47,7 +52,10 @@ async fn emits_warning_when_resumed_model_differs() { let initial_history = resume_history(&config, "previous-model", &rollout_path); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); // Act: resume the conversation. diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index b3a52cfa54..ca8af6ad1e 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use codex_core::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses::get_responses_requests; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -394,7 +395,7 @@ async fn review_uses_custom_review_model_from_config() { let codex_home = TempDir::new().unwrap(); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, &codex_home, |cfg| { - cfg.model = "gpt-4.1".to_string(); + cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = "gpt-5.1".to_string(); }) .await; @@ -425,7 +426,10 @@ async fn review_uses_custom_review_model_from_config() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request body model equals the configured review model - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1"); @@ -543,7 +547,10 @@ async fn review_input_isolated_from_parent_history() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request `input` contains the environment context followed by the user review prompt. - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); assert_eq!( @@ -673,7 +680,7 @@ async fn review_history_surfaces_in_parent_session() { // Inspect the second request (parent turn) input contents. // Parent turns include session initial messages (user_instructions, environment_context). // Critically, no messages from the review thread should appear. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 2); let body = requests[1].body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); @@ -743,8 +750,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); conversation_manager .new_conversation(config) .await @@ -770,8 +779,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); conversation_manager diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cc653a9c56..ef2fc16ede 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -487,9 +487,13 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { // Chat Completions assertion: the second POST should include a tool role message // with an array `content` containing an item with the expected data URL. - let requests = server.received_requests().await.expect("requests captured"); + let all_requests = server.received_requests().await.expect("requests captured"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); assert!(requests.len() >= 2, "expected two chat completion calls"); - let second = &requests[1]; + let second = requests[1]; let body: Value = serde_json::from_slice(&second.body)?; let messages = body .get("messages") diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index e2dcb0c567..15ce32e53f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -18,6 +18,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -1240,10 +1241,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let metadata = outputs @@ -1347,10 +1345,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs @@ -1475,10 +1470,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1727,10 +1719,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1864,10 +1853,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1976,10 +1962,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -2065,10 +2048,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let large_output = outputs.get(call_id).expect("missing large output summary"); @@ -2145,10 +2125,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); @@ -2246,10 +2223,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let startup_output = outputs @@ -2339,10 +2313,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 964cc58d50..8472399ce4 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -42,8 +42,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -99,8 +101,10 @@ async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 6eec8b71fc..1da0796a75 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -140,7 +140,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { VERSION ); - let mut entries = create_config_summary_entries(config); + let mut entries = + create_config_summary_entries(config, session_configured_event.model.as_str()); entries.push(( "session id", session_configured_event.session_id.to_string(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7dfeeecf7b..7d7d4c301f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -263,7 +263,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -278,6 +277,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.cli_auth_credentials_store_mode, ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); + let default_model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { diff --git a/codex-rs/lmstudio/src/lib.rs b/codex-rs/lmstudio/src/lib.rs index bb8c8cef6a..fd4f82a728 100644 --- a/codex-rs/lmstudio/src/lib.rs +++ b/codex-rs/lmstudio/src/lib.rs @@ -11,7 +11,10 @@ pub const DEFAULT_OSS_MODEL: &str = "openai/gpt-oss-20b"; /// - Ensures a local LM Studio server is reachable. /// - Checks if the model exists locally and downloads it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { - let model: &str = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local LM Studio is reachable. let lmstudio_client = LMStudioClient::try_from_provider(config).await?; diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 0ebf1662ac..4ced3b6276 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -19,7 +19,10 @@ pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; /// - Checks if the model exists locally and pulls it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { // Only download when the requested model is the default OSS model (or when -m is not provided). - let model = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local Ollama is reachable. let ollama_client = crate::OllamaClient::try_from_oss_provider(config).await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0a09b15e7c..1ce3b4fd51 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -123,14 +123,15 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Optio async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, + model: &str, app_event_tx: &AppEventSender, auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.list_models().await; + let available_models = models_manager.list_models(config).await; let upgrade = available_models .iter() - .find(|preset| preset.model == config.model) + .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { @@ -146,7 +147,7 @@ async fn handle_model_migration_prompt_if_needed( let target_model = target_model.to_string(); let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( - &config.model, + model, &target_model, hide_prompt_flag, available_models.clone(), @@ -160,7 +161,7 @@ async fn handle_model_migration_prompt_if_needed( app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { migration_config: migration_config_key.to_string(), }); - config.model = target_model.to_string(); + config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -207,6 +208,7 @@ pub(crate) struct App { pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -269,9 +271,14 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, + model.as_str(), &app_event_tx, auth_mode, conversation_manager.get_models_manager(), @@ -280,6 +287,9 @@ impl App { if let Some(exit_info) = exit_info { return Ok(exit_info); } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } let skills_outcome = load_skills(&config); if !skills_outcome.errors.is_empty() { @@ -304,7 +314,7 @@ impl App { let enhanced_keys_supported = tui.enhanced_keys_supported(); let model_family = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family(model.as_str(), &config) .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { @@ -320,7 +330,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -347,7 +357,7 @@ impl App { feedback: feedback.clone(), skills: skills.clone(), is_first_run, - model_family, + model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -369,6 +379,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, + current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -489,7 +500,7 @@ impl App { let model_family = self .server .get_models_manager() - .construct_model_family(&self.config.model, &self.config) + .construct_model_family(self.current_model.as_str(), &self.config) .await; match event { AppEvent::NewSession => { @@ -510,9 +521,10 @@ impl App { feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, - model_family, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -567,6 +579,7 @@ impl App { resumed.conversation, resumed.session_configured, ); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -695,7 +708,7 @@ impl App { .construct_model_family(&model, &self.config) .await; self.chat_widget.set_model(&model, model_family); - self.config.model = model; + self.current_model = model; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1167,9 +1180,11 @@ mod tests { fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1180,6 +1195,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1204,9 +1220,11 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -1218,6 +1236,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1343,6 +1362,7 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), + app.current_model.as_str(), event, is_first, )) as Arc diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index ca9de52e2a..deb629765a 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,9 +338,10 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, - model_family: self.chat_widget.get_model_family(), + model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -354,6 +355,7 @@ impl App { }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f9e53c8055..ea29c00d93 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -399,6 +399,7 @@ impl ChatWidget { self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, + &model_for_header, event, self.show_welcome_banner, )); @@ -625,7 +626,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.config.model != NUDGE_MODEL_SLUG + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1265,6 +1266,9 @@ impl ChatWidget { is_first_run, model_family, } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1284,11 +1288,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1348,6 +1352,7 @@ impl ChatWidget { model_family, .. } = common; + let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1369,11 +1374,11 @@ impl ChatWidget { skills, }), active_cell: None, - config: config.clone(), + config, model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -2035,6 +2040,7 @@ impl ChatWidget { self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), + self.model_family.get_model_slug(), )); } fn stop_rate_limit_poller(&mut self) { @@ -2177,7 +2183,7 @@ impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models() { @@ -2284,7 +2290,7 @@ impl ChatWidget { return; } - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = @@ -2413,7 +2419,7 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.config.model == preset.model; + let is_current_model = self.model_family.get_model_slug() == preset.model; let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -2970,7 +2976,6 @@ impl ChatWidget { /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.config.model = model.to_string(); self.model_family = model_family; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2355493250..c54f0da3d5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -75,6 +75,7 @@ fn set_windows_sandbox_enabled(enabled: bool) { fn test_config() -> Config { // Use base defaults to avoid depending on host state. + Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -346,10 +347,12 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); - let model_family = ModelsManager::construct_model_family_offline(&cfg.model, &cfg); - let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "test", - ))); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { config: cfg, @@ -382,8 +385,11 @@ fn make_chatwidget_manual( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let mut cfg = test_config(); + let resolved_model = model_override + .map(str::to_owned) + .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); if let Some(model) = model_override { - cfg.model = model.to_string(); + cfg.model = Some(model.to_string()); } let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), @@ -402,10 +408,10 @@ fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - model_family: ModelsManager::construct_model_family_offline(&cfg.model, &cfg), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), - session_header: SessionHeader::new(cfg.model), + session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, token_info: None, rate_limit_snapshot: None, @@ -650,10 +656,9 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(None); + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = NUDGE_MODEL_SLUG.to_string(); chat.on_rate_limit_snapshot(Some(snapshot(95.0))); @@ -666,8 +671,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -691,8 +695,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -707,8 +710,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(None); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); @@ -728,10 +730,9 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = "gpt-5".to_string(); chat.on_rate_limit_snapshot(Some(snapshot(92.0))); chat.maybe_show_pending_rate_limit_prompt(); @@ -1774,9 +1775,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5-codex".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -1879,10 +1878,9 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1894,10 +1892,9 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1909,10 +1906,9 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -1992,9 +1988,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); - - chat.config.model = "gpt-5.1".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex"); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 945ed1f491..4147067366 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -621,6 +621,7 @@ impl HistoryCell for SessionInfoCell { pub(crate) fn new_session_info( config: &Config, + requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, ) -> SessionInfoCell { @@ -679,10 +680,10 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if config.model != model { + if requested_model != model { let lines = vec![ "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), + format!("requested: {requested_model}").into(), format!("used: {model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); @@ -2321,10 +2322,7 @@ mod tests { } #[test] fn reasoning_summary_block() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), reasoning_format, @@ -2339,10 +2337,7 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "Detailed reasoning goes here.".to_string(), reasoning_format, @@ -2355,10 +2350,11 @@ mod tests { #[test] fn reasoning_summary_block_respects_config_overrides() { let mut config = test_config(); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let model_family = ModelsManager::construct_model_family_offline(&config.model, &config); + let model_family = + ModelsManager::construct_model_family_offline(&config.model.clone().unwrap(), &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2375,10 +2371,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), reasoning_format, @@ -2390,10 +2383,7 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), reasoning_format.clone(), @@ -2413,10 +2403,7 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { - let config = test_config(); - let reasoning_format = - ModelsManager::construct_model_family_offline(&config.model, &config) - .reasoning_summary_format; + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), reasoning_format, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7049d13fff..aac981c764 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -78,6 +78,7 @@ pub(crate) fn new_status_output( rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -90,6 +91,7 @@ pub(crate) fn new_status_output( rate_limits, plan_type, now, + model_name, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -107,9 +109,10 @@ impl StatusHistoryCell { rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> Self { - let config_entries = create_config_summary_entries(config); - let (model_name, model_details) = compose_model_display(config, &config_entries); + let config_entries = create_config_summary_entries(config, model_name); + let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() .find(|(k, _)| *k == "approval") diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index cb6b7b54b2..8ba7ec3775 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -17,7 +17,7 @@ fn normalize_agents_display_path(path: &Path) -> String { } pub(crate) fn compose_model_display( - config: &Config, + model_name: &str, entries: &[(&str, String)], ) -> (String, Vec) { let mut details: Vec = Vec::new(); @@ -33,7 +33,7 @@ pub(crate) fn compose_model_display( } } - (config.model.clone(), details) + (model_name.to_string(), details) } pub(crate) fn compose_agents_summary(config: &Config) -> String { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 1b16453c42..53c728526a 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -39,8 +39,8 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } -fn test_model_family(config: &Config) -> ModelFamily { - ModelsManager::construct_model_family_offline(config.model.as_str(), config) +fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(model_slug, config) } fn render_lines(lines: &[Line<'static>]) -> Vec { @@ -88,7 +88,7 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -130,7 +130,8 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, @@ -142,6 +143,7 @@ fn status_snapshot_includes_reasoning_details() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -157,7 +159,7 @@ fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.cwd = PathBuf::from("/workspace/tests"); @@ -186,7 +188,8 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -197,6 +200,7 @@ fn status_snapshot_includes_monthly_limit() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -229,7 +233,8 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -240,6 +245,7 @@ fn status_snapshot_shows_unlimited_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -271,7 +277,8 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -282,6 +289,7 @@ fn status_snapshot_shows_positive_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -313,7 +321,8 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -324,6 +333,7 @@ fn status_snapshot_hides_zero_credits() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -353,7 +363,8 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -364,6 +375,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -376,7 +388,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -393,7 +405,8 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -404,6 +417,7 @@ fn status_card_token_usage_excludes_cached_tokens() { None, None, now, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); @@ -417,7 +431,7 @@ fn status_card_token_usage_excludes_cached_tokens() { fn status_snapshot_truncates_in_narrow_terminal() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; @@ -448,7 +462,8 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -459,6 +474,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -475,7 +491,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -492,7 +508,8 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -503,6 +520,7 @@ fn status_snapshot_shows_missing_limits_message() { None, None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -518,7 +536,7 @@ fn status_snapshot_shows_missing_limits_message() { fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -554,7 +572,8 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -565,6 +584,7 @@ fn status_snapshot_includes_credits_and_limits() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -580,7 +600,7 @@ fn status_snapshot_includes_credits_and_limits() { fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -604,7 +624,8 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -615,6 +636,7 @@ fn status_snapshot_shows_empty_limits_message() { Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -630,7 +652,7 @@ fn status_snapshot_shows_empty_limits_message() { fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -663,7 +685,8 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -674,6 +697,7 @@ fn status_snapshot_shows_stale_limits_message() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -689,7 +713,7 @@ fn status_snapshot_shows_stale_limits_message() { fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -726,7 +750,8 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -737,6 +762,7 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -775,7 +801,8 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); - let model_family = test_model_family(&config); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, @@ -786,6 +813,7 @@ fn status_context_window_uses_last_usage() { None, None, now, + &model_slug, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines From 1a5809624d3909128d016fa3a28ff38f217fbc9c Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Thu, 11 Dec 2025 03:38:15 +0800 Subject: [PATCH 127/159] fix: Prevent slash command popup from activating on invalid inputs (#7704) ## Slash Command popup issue #7659 When recalling history, the composer(`codex_tui::bottom_pane::chat_composer`) restores the previous prompt text (which may start with `/`) and then calls `sync_command_popup`. The logic in `sync_command_popup` treats any first line that starts with `/` and has the caret inside the initial `/name` token as an active slash command name: ```rust let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { let token_end = first_line .char_indices() .find(|(_, c)| c.is_whitespace()) .map(|(i, _)| i) .unwrap_or(first_line.len()); cursor <= token_end } else { false }; ``` This detection does not distinguish between an actual interactive slash command being typed and a normal historical prompt that happens to begin with `/`. As a result, after history recall, the restored prompt like `/ test` is interpreted as an "editing command name" context and the slash-command popup is (re)activated. Once `active_popup` is `ActivePopup::Command`, subsequent `Up` key presses are handled by `handle_key_event_with_slash_popup` instead of `handle_key_event_without_popup`, so they no longer trigger `history.navigate_up(...)` and the session prompt history cannot be scrolled. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 136 ++++++++++++++++-- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4deb5125c1..ed498e949c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1579,6 +1579,56 @@ impl ChatComposer { } } + /// If the cursor is currently within a slash command on the first line, + /// extract the command name and the rest of the line after it. + /// Returns None if the cursor is outside a slash command. + fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { + if !first_line.starts_with('/') { + return None; + } + + let name_start = 1usize; + let name_end = first_line[name_start..] + .find(char::is_whitespace) + .map(|idx| name_start + idx) + .unwrap_or_else(|| first_line.len()); + + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..name_end]; + let rest_start = first_line[name_end..] + .find(|c: char| !c.is_whitespace()) + .map(|idx| name_end + idx) + .unwrap_or(name_end); + let rest = &first_line[rest_start..]; + + Some((name, rest)) + } + + /// Heuristic for whether the typed slash command looks like a valid + /// prefix for any known command (built-in or custom prompt). + /// Empty names only count when there is no extra content after the '/'. + fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if name.is_empty() { + return rest_after_name.is_empty(); + } + + let builtin_match = built_in_slash_commands() + .into_iter() + .any(|(cmd_name, _)| cmd_name.starts_with(name)); + + if builtin_match { + return true; + } + + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + self.custom_prompts + .iter() + .any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name)) + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -1596,17 +1646,10 @@ impl ChatComposer { let cursor = self.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; - let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { - // Compute the end of the initial '/name' token (name may be empty yet). - let token_end = first_line - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(i, _)| i) - .unwrap_or(first_line.len()); - cursor <= token_end - } else { - false - }; + let is_editing_slash_command_name = caret_on_first_line + && Self::slash_command_under_cursor(first_line, cursor) + .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); + // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path // as an argument to the command (e.g., "/review @docs/..."). @@ -3873,4 +3916,75 @@ mod tests { assert_eq!(composer.textarea.text(), "z".repeat(count)); assert!(composer.pending_pastes.is_empty()); } + + #[test] + fn slash_popup_not_activated_for_slash_space_text_history_like_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Simulate history-like content: "/ test" + composer.set_text_content("/ test".to_string()); + + // After set_text_content -> sync_popups is called; popup should NOT be Command. + assert!( + matches!(composer.active_popup, ActivePopup::None), + "expected no slash popup for '/ test'" + ); + + // Up should be handled by history navigation path, not slash popup handler. + let (result, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + } + + #[test] + fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { + // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Case 1: bare "/" + composer.set_text_content("/".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "bare '/' should activate slash popup" + ); + + // Case 2: valid prefix "/re" (matches /review, /resume, etc.) + composer.set_text_content("/re".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "'/re' should activate slash popup via prefix match" + ); + + // Case 3: invalid prefix "/zzz" – still allowed to open popup if it + // matches no built-in command, our current logic will *not* open popup. + // Verify that explicitly. + composer.set_text_content("/zzz".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::None), + "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" + ); + } } From 4953b2ae09d4999c78295bf3460a3a49b58ed3e5 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 12:15:39 -0800 Subject: [PATCH 128/159] Error when trying to push a release while another release is in progress (#7834) image Currently, we just cancel the in progress release which can be annoying --- codex-rs/scripts/create_github_release | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index e7b8972db5..fffd987bc1 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -59,6 +59,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: args = parse_args(argv) + ensure_release_not_in_progress() + # Strip the leading "v" if present. promote_alpha = args.promote_alpha if promote_alpha and promote_alpha.startswith("v"): @@ -144,6 +146,36 @@ def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = Non raise ReleaseError("Failed to parse response from gh api.") from error +def ensure_release_not_in_progress() -> None: + """Fail fast if a release workflow is already running or queued.""" + + statuses = ("in_progress", "queued") + runs: list[dict] = [] + for status in statuses: + response = run_gh_api( + f"/repos/{REPO}/actions/workflows/rust-release.yml/runs?per_page=50&status={status}" + ) + runs.extend(response.get("workflow_runs", [])) + + active_runs = [run for run in runs if run.get("status") in statuses] + if not active_runs: + return + + seen_ids: set[int] = set() + urls: list[str] = [] + for run in active_runs: + run_id = run.get("id") + if run_id in seen_ids: + continue + seen_ids.add(run_id) + urls.append(run.get("html_url", str(run_id))) + + raise ReleaseError( + "Release workflow already running or queued; wait or cancel it before publishing: " + + ", ".join(urls) + ) + + def get_branch_head() -> str: response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}") try: From bfb4d5710b883df074ff3af8da3766dca83cfd2f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 10 Dec 2025 13:35:31 -0800 Subject: [PATCH 129/159] [app-server-protocol] Add types for config (#7658) Currently the config returned by `config/read` in untyped. Add types so it's easier for client to parse the config. Since currently configs are all defined in snake case we'll keep that instead of using camel case like the rest of V2. Sample output by testing using the app server test client: ``` { < "id": "f28449f4-b015-459b-b07b-eef06980165d", < "result": { < "config": { < "approvalPolicy": null, < "compactPrompt": null, < "developerInstructions": null, < "features": { < "experimental_use_rmcp_client": true < }, < "forcedChatgptWorkspaceId": null, < "forcedLoginMethod": null, < "instructions": null, < "model": "gpt-5.1-codex-max", < "modelAutoCompactTokenLimit": null, < "modelContextWindow": null, < "modelProvider": null, < "modelReasoningEffort": null, < "modelReasoningSummary": null, < "modelVerbosity": null, < "model_providers": { < "local": { < "base_url": "http://localhost:8061/api/codex", < "env_http_headers": { < "ChatGPT-Account-ID": "OPENAI_ACCOUNT_ID" < }, < "env_key": "CHATGPT_TOKEN_STAGING", < "name": "local", < "wire_api": "responses" < } < }, < "model_reasoning_effort": "medium", < "notice": { < "hide_gpt-5.1-codex-max_migration_prompt": true, < "hide_gpt5_1_migration_prompt": true < }, < "profile": null, < "profiles": {}, < "projects": { < "/Users/celia/code": { < "trust_level": "trusted" < }, < "/Users/celia/code/codex": { < "trust_level": "trusted" < }, < "/Users/celia/code/openai": { < "trust_level": "trusted" < } < }, < "reviewModel": null, < "sandboxMode": null, < "sandboxWorkspaceWrite": null, < "tools": { < "viewImage": null, < "webSearch": null < } < }, < "origins": { < "features.experimental_use_rmcp_client": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.base_url": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.env_http_headers.ChatGPT-Account-ID": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.env_key": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.name": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_providers.local.wire_api": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "model_reasoning_effort": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "notice.hide_gpt-5.1-codex-max_migration_prompt": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "notice.hide_gpt5_1_migration_prompt": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code/codex.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "projects./Users/celia/code/openai.trust_level": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < }, < "tools.web_search": { < "name": "user", < "source": "/Users/celia/.codex/config.toml", < "version": "sha256:a1d8eaedb5d9db5dfdfa69f30fa9df2efec66bb4dd46aa67f149fcc67cd0711c" < } < } < } < } ``` --- .../app-server-protocol/src/protocol/v2.rs | 141 ++++++++++++++++-- codex-rs/app-server/src/config_api.rs | 19 ++- .../app-server/tests/suite/v2/config_rpc.rs | 114 ++++++++++---- 3 files changed, 223 insertions(+), 51 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 211f0ba375..edd3aefa74 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,10 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::config_types::Verbosity; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; @@ -12,6 +15,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -122,17 +126,68 @@ impl From for CodexErrorInfo { } } -v2_enum_from_core!( - pub enum AskForApproval from codex_protocol::protocol::AskForApproval { - UnlessTrusted, OnFailure, OnRequest, Never - } -); +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + Never, +} -v2_enum_from_core!( - pub enum SandboxMode from codex_protocol::config_types::SandboxMode { - ReadOnly, WorkspaceWrite, DangerFullAccess +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Never => CoreAskForApproval::Never, + } } -); +} + +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Never => AskForApproval::Never, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} v2_enum_from_core!( pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { @@ -159,6 +214,72 @@ pub enum ConfigLayerName { User, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + #[serde(alias = "web_search_request")] + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub tools: Option, + pub profile: Option, + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,7 +358,7 @@ pub struct ConfigReadParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadResponse { - pub config: JsonValue, + pub config: Config, pub origins: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub layers: Option>, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 98fe93fb25..c1eaf62d26 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,5 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use codex_app_server_protocol::Config; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -75,8 +76,10 @@ impl ConfigApi { let effective = layers.effective_config(); validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; + let config: Config = serde_json::from_value(to_json_value(&effective)) + .map_err(|err| internal_error("failed to deserialize configuration", err))?; let response = ConfigReadResponse { - config: to_json_value(&effective), + config, origins: layers.origins(), layers: params.include_layers.then(|| layers.layers_high_to_low()), }; @@ -773,6 +776,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> mod tests { use super::*; use anyhow::Result; + use codex_app_server_protocol::AskForApproval; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -895,10 +899,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!( - response.config.get("approval_policy"), - Some(&json!("never")) - ); + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); assert_eq!( response @@ -953,8 +954,10 @@ remote_compaction = true }) .await .expect("read"); - let config_object = read_after.config.as_object().expect("object"); - assert_eq!(config_object.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); assert_eq!( read_after .origins @@ -1093,7 +1096,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!(response.config.get("model"), Some(&json!("system"))); + assert_eq!(response.config.model.as_deref(), Some("system")); assert_eq!( response.origins.get("model").expect("origin").name, ConfigLayerName::System diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index eb3ece64b2..b6615ef667 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerName; @@ -12,9 +13,12 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +61,7 @@ sandbox_mode = "workspace-write" layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-user"))); + assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::User @@ -70,6 +74,64 @@ sandbox_mode = "workspace-write" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_tools() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" + +[tools] +web_search = true +view_image = false +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + let tools = config.tools.expect("tools present"); + assert_eq!( + tools, + ToolsV2 { + web_search: Some(true), + view_image: Some(false), + } + ); + assert_eq!( + origins.get("tools.web_search").expect("origin").name, + ConfigLayerName::User + ); + assert_eq!( + origins.get("tools.view_image").expect("origin").name, + ConfigLayerName::User + ); + + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[1].name, ConfigLayerName::User); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -123,30 +185,29 @@ writable_roots = ["/system"] layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-system"))); + assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("approval_policy"), Some(&json!("never"))); + assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("sandbox_mode"), Some(&json!("workspace-write"))); + assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, ConfigLayerName::User ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/system"])) - ); + let sandbox = config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/system")]); assert_eq!( origins .get("sandbox_workspace_write.writable_roots.0") @@ -155,12 +216,7 @@ writable_roots = ["/system"] ConfigLayerName::System ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(true)) - ); + assert!(sandbox.network_access); assert_eq!( origins .get("sandbox_workspace_write.network_access") @@ -242,7 +298,7 @@ model = "gpt-old" ) .await??; let verify: ConfigReadResponse = to_response(verify_resp)?; - assert_eq!(verify.config.get("model"), Some(&json!("gpt-new"))); + assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); Ok(()) } @@ -342,22 +398,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { ) .await??; let read: ConfigReadResponse = to_response(read_resp)?; - assert_eq!( - read.config.get("sandbox_mode"), - Some(&json!("workspace-write")) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/tmp"])) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(false)) - ); + assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + let sandbox = read + .config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/tmp")]); + assert!(!sandbox.network_access); Ok(()) } From eb2e5458ccbbe07d446a091a22dfddcb0af13bb2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 10 Dec 2025 13:56:48 -0800 Subject: [PATCH 130/159] Disable ansi codes in tui log file (#7836) --- codex-rs/tui/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d9793a07a0..71a47d1198 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -269,6 +269,7 @@ pub async fn run_main( let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) + .with_ansi(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); From b36ecb6c3276ad399c8b8510d2f8fe7ef4631c2e Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 10 Dec 2025 13:59:17 -0800 Subject: [PATCH 131/159] Inject SKILL.md when it's explicitly mentioned. (#7763) 1. Skills load once in core at session start; the cached outcome is reused across core and surfaced to TUI via SessionConfigured. 2. TUI detects explicit skill selections, and core injects the matching SKILL.md content into the turn when a selected skill is present. --- codex-rs/core/src/codex.rs | 81 ++++++++++- codex-rs/core/src/event_mapping.rs | 29 ++-- codex-rs/core/src/project_doc.rs | 64 +++++---- codex-rs/core/src/skills/injection.rs | 78 ++++++++++ codex-rs/core/src/skills/mod.rs | 3 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/user_instructions.rs | 75 ++++++++++ codex-rs/core/tests/common/test_codex.rs | 18 +++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/skills.rs | 136 ++++++++++++++++++ .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 2 + codex-rs/protocol/src/models.rs | 25 ++-- codex-rs/protocol/src/protocol.rs | 23 +++ codex-rs/protocol/src/user_input.rs | 6 + codex-rs/tui/src/app.rs | 60 ++++---- codex-rs/tui/src/app_backtrack.rs | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 + codex-rs/tui/src/bottom_pane/mod.rs | 9 ++ codex-rs/tui/src/chatwidget.rs | 52 ++++++- codex-rs/tui/src/chatwidget/tests.rs | 2 +- 21 files changed, 584 insertions(+), 88 deletions(-) create mode 100644 codex-rs/core/src/skills/injection.rs create mode 100644 codex-rs/core/tests/suite/skills.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 22570ad1b3..e23e03298d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -97,6 +97,9 @@ use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::SkillErrorInfo; +use crate::protocol::SkillInfo; +use crate::protocol::SkillLoadOutcomeInfo; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -109,6 +112,10 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillInjections; +use crate::skills::SkillLoadOutcome; +use crate::skills::build_skill_injections; +use crate::skills::load_skills; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -173,7 +180,31 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let user_instructions = get_user_instructions(&config).await; + let loaded_skills = if config.features.enabled(Feature::Skills) { + Some(load_skills(&config)) + } else { + None + }; + + if let Some(outcome) = &loaded_skills { + for err in &outcome.errors { + error!( + "failed to load skill {}: {}", + err.path.display(), + err.message + ); + } + } + + let skills_outcome = loaded_skills.clone(); + + let user_instructions = get_user_instructions( + &config, + skills_outcome + .as_ref() + .map(|outcome| outcome.skills.as_slice()), + ) + .await; let exec_policy = load_exec_policy_for_features(&config.features, &config.codex_home) .await @@ -206,6 +237,7 @@ impl Codex { // Generate a unique ID for the lifetime of this Codex session. let session_source_clone = session_configuration.session_source.clone(); + let session = Session::new( session_configuration, config.clone(), @@ -214,6 +246,7 @@ impl Codex { tx_event.clone(), conversation_history, session_source_clone, + skills_outcome.clone(), ) .await .map_err(|e| { @@ -471,6 +504,7 @@ impl Session { } } + #[allow(clippy::too_many_arguments)] async fn new( session_configuration: SessionConfiguration, config: Arc, @@ -479,6 +513,7 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, + skills: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -596,6 +631,7 @@ impl Session { otel_event_manager, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + skills: skills.clone(), }; let sess = Arc::new(Session { @@ -611,6 +647,7 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref()); let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -625,6 +662,7 @@ impl Session { history_log_id, history_entry_count, initial_messages, + skill_load_outcome, rollout_path, }), }) @@ -1978,6 +2016,30 @@ async fn spawn_review_thread( .await; } +fn skill_load_outcome_for_client( + outcome: Option<&SkillLoadOutcome>, +) -> Option { + outcome.map(|outcome| SkillLoadOutcomeInfo { + skills: outcome + .skills + .iter() + .map(|skill| SkillInfo { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect(), + errors: outcome + .errors + .iter() + .map(|err| SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect(), + }) +} + /// Takes a user message as input and runs a loop where, at each turn, the model /// replies with either: /// @@ -2006,11 +2068,26 @@ pub(crate) async fn run_task( }); sess.send_event(&turn_context, event).await; + let SkillInjections { + items: skill_items, + warnings: skill_warnings, + } = build_skill_injections(&input, sess.services.skills.as_ref()).await; + + for message in skill_warnings { + sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let response_item: ResponseItem = initial_input_for_turn.clone().into(); sess.record_response_item_and_emit_turn_item(turn_context.as_ref(), response_item) .await; + if !skill_items.is_empty() { + sess.record_conversation_items(&turn_context, &skill_items) + .await; + } + sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; let mut last_agent_message: Option = None; @@ -2860,6 +2937,7 @@ mod tests { otel_event_manager: otel_event_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Session::make_turn_context( @@ -2945,6 +3023,7 @@ mod tests { otel_event_manager: otel_event_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Arc::new(Session::make_turn_context( diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6b4bed4db3..6ab6291a4b 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; +use crate::user_instructions::SkillInstructions; use crate::user_instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; @@ -23,7 +24,9 @@ fn is_session_prefix(text: &str) -> bool { } fn parse_user_message(message: &[ContentItem]) -> Option { - if UserInstructions::is_user_instructions(message) { + if UserInstructions::is_user_instructions(message) + || SkillInstructions::is_skill_instructions(message) + { return None; } @@ -198,14 +201,22 @@ mod tests { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "echo 42".to_string(), - }], - }, - ]; + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\ndemo\nskills/demo/SKILL.md\nbody\n" + .to_string(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "echo 42".to_string(), + }], + }, + ]; for item in items { let turn_item = parse_turn_item(&item); diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 43a0034801..cd05520110 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -15,7 +15,7 @@ use crate::config::Config; use crate::features::Feature; -use crate::skills::load_skills; +use crate::skills::SkillMetadata; use crate::skills::render_skills_section; use dunce::canonicalize as normalize_path; use std::path::PathBuf; @@ -33,17 +33,12 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// string of instructions. -pub(crate) async fn get_user_instructions(config: &Config) -> Option { +pub(crate) async fn get_user_instructions( + config: &Config, + skills: Option<&[SkillMetadata]>, +) -> Option { let skills_section = if config.features.enabled(Feature::Skills) { - let skills_outcome = load_skills(config); - for err in &skills_outcome.errors { - error!( - "failed to load skill {}: {}", - err.path.display(), - err.message - ); - } - render_skills_section(&skills_outcome.skills) + skills.and_then(render_skills_section) } else { None }; @@ -244,6 +239,7 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::skills::load_skills; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -289,7 +285,7 @@ mod tests { async fn no_doc_file_returns_none() { let tmp = tempfile::tempdir().expect("tempdir"); - let res = get_user_instructions(&make_config(&tmp, 4096, None)).await; + let res = get_user_instructions(&make_config(&tmp, 4096, None), None).await; assert!( res.is_none(), "Expected None when AGENTS.md is absent and no system instructions provided" @@ -303,7 +299,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 4096, None)) + let res = get_user_instructions(&make_config(&tmp, 4096, None), None) .await .expect("doc expected"); @@ -322,7 +318,7 @@ mod tests { let huge = "A".repeat(LIMIT * 2); // 2 KiB fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); - let res = get_user_instructions(&make_config(&tmp, LIMIT, None)) + let res = get_user_instructions(&make_config(&tmp, LIMIT, None), None) .await .expect("doc expected"); @@ -354,7 +350,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root level doc"); } @@ -364,7 +362,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 0, None)).await; + let res = get_user_instructions(&make_config(&tmp, 0, None), None).await; assert!( res.is_none(), "With limit 0 the function should return None" @@ -380,7 +378,7 @@ mod tests { const INSTRUCTIONS: &str = "base instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))) + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None) .await .expect("should produce a combined instruction string"); @@ -397,7 +395,7 @@ mod tests { const INSTRUCTIONS: &str = "some instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))).await; + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None).await; assert_eq!(res, Some(INSTRUCTIONS.to_string())); } @@ -426,7 +424,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); } @@ -439,7 +439,7 @@ mod tests { let cfg = make_config(&tmp, 4096, None); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("local doc expected"); @@ -461,7 +461,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("fallback doc expected"); @@ -477,7 +477,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("AGENTS.md should win"); @@ -506,9 +506,13 @@ mod tests { "extract from pdfs", ); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize( cfg.codex_home .join("skills/pdf-processing/SKILL.md") @@ -529,9 +533,13 @@ mod tests { let cfg = make_config(&tmp, 4096, None); create_skill(cfg.codex_home.clone(), "linting", "run clippy"); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs new file mode 100644 index 0000000000..a143fce1f2 --- /dev/null +++ b/codex-rs/core/src/skills/injection.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::user_instructions::SkillInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use tokio::fs; + +#[derive(Debug, Default)] +pub(crate) struct SkillInjections { + pub(crate) items: Vec, + pub(crate) warnings: Vec, +} + +pub(crate) async fn build_skill_injections( + inputs: &[UserInput], + skills: Option<&SkillLoadOutcome>, +) -> SkillInjections { + if inputs.is_empty() { + return SkillInjections::default(); + } + + let Some(outcome) = skills else { + return SkillInjections::default(); + }; + + let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + if mentioned_skills.is_empty() { + return SkillInjections::default(); + } + + let mut result = SkillInjections { + items: Vec::with_capacity(mentioned_skills.len()), + warnings: Vec::new(), + }; + + for skill in mentioned_skills { + match fs::read_to_string(&skill.path).await { + Ok(contents) => { + result.items.push(ResponseItem::from(SkillInstructions { + name: skill.name, + path: skill.path.to_string_lossy().into_owned(), + contents, + })); + } + Err(err) => { + let message = format!( + "Failed to load skill {} at {}: {err:#}", + skill.name, + skill.path.display() + ); + result.warnings.push(message); + } + } + } + + result +} + +fn collect_explicit_skill_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], +) -> Vec { + let mut selected: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for input in inputs { + if let UserInput::Skill { name, path } = input + && seen.insert(name.clone()) + && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + { + selected.push(skill.clone()); + } + } + + selected +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index ebb1490c99..b2ab935ce5 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,7 +1,10 @@ +pub mod injection; pub mod loader; pub mod model; pub mod render; +pub(crate) use injection::SkillInjections; +pub(crate) use injection::build_skill_injections; pub use loader::load_skills; pub use model::SkillError; pub use model::SkillLoadOutcome; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 7387bcedae..0270f3411c 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,6 +4,7 @@ use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_models::models_manager::ModelsManager; +use crate::skills::SkillLoadOutcome; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -24,4 +25,5 @@ pub(crate) struct SessionServices { pub(crate) models_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, pub(crate) tool_approvals: Mutex, + pub(crate) skills: Option, } diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/user_instructions.rs index 61f8d7fde4..22b5ad7bbe 100644 --- a/codex-rs/core/src/user_instructions.rs +++ b/codex-rs/core/src/user_instructions.rs @@ -6,6 +6,7 @@ use codex_protocol::models::ResponseItem; pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = ""; pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +pub const SKILL_INSTRUCTIONS_PREFIX: &str = " for ResponseItem { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename = "skill_instructions", rename_all = "snake_case")] +pub(crate) struct SkillInstructions { + pub name: String, + pub path: String, + pub contents: String, +} + +impl SkillInstructions { + pub fn is_skill_instructions(message: &[ContentItem]) -> bool { + if let [ContentItem::InputText { text }] = message { + text.starts_with(SKILL_INSTRUCTIONS_PREFIX) + } else { + false + } + } +} + +impl From for ResponseItem { + fn from(si: SkillInstructions) -> Self { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "\n{}\n{}\n{}\n", + si.name, si.path, si.contents + ), + }], + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "developer_instructions", rename_all = "snake_case")] pub(crate) struct DeveloperInstructions { @@ -72,6 +106,7 @@ impl From for ResponseItem { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_user_instructions() { @@ -115,4 +150,44 @@ mod tests { } ])); } + + #[test] + fn test_skill_instructions() { + let skill_instructions = SkillInstructions { + name: "demo-skill".to_string(), + path: "skills/demo/SKILL.md".to_string(), + contents: "body".to_string(), + }; + let response_item: ResponseItem = skill_instructions.into(); + + let ResponseItem::Message { role, content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + assert_eq!(role, "user"); + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n", + ); + } + + #[test] + fn test_is_skill_instructions() { + assert!(SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" + .to_string(), + } + ])); + assert!(!SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "regular text".to_string(), + } + ])); + } } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5f38dbd4b5..b07f4d3741 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -28,6 +28,7 @@ use crate::responses::start_mock_server; use crate::wait_for_event; type ConfigMutator = dyn FnOnce(&mut Config) + Send; +type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; /// A collection of different ways the model can output an apply_patch call #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -51,6 +52,7 @@ pub enum ShellModelOutput { pub struct TestCodexBuilder { config_mutators: Vec>, auth: CodexAuth, + pre_build_hooks: Vec>, } impl TestCodexBuilder { @@ -74,6 +76,14 @@ impl TestCodexBuilder { }) } + pub fn with_pre_build_hook(mut self, hook: F) -> Self + where + F: FnOnce(&Path) + Send + 'static, + { + self.pre_build_hooks.push(Box::new(hook)); + self + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { let home = Arc::new(TempDir::new()?); self.build_with_home(server, home, None).await @@ -137,6 +147,9 @@ impl TestCodexBuilder { let mut config = load_default_config_for_test(home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; + for hook in self.pre_build_hooks.drain(..) { + hook(home.path()); + } if let Ok(cmd) = assert_cmd::Command::cargo_bin("codex") { config.codex_linux_sandbox_exe = Some(PathBuf::from(cmd.get_program().to_os_string())); } @@ -171,6 +184,10 @@ impl TestCodex { self.cwd.path() } + pub fn codex_home_path(&self) -> &Path { + self.config.codex_home.as_path() + } + pub fn workspace_path(&self, rel: impl AsRef) -> PathBuf { self.cwd_path().join(rel) } @@ -351,5 +368,6 @@ pub fn test_codex() -> TestCodexBuilder { TestCodexBuilder { config_mutators: vec![], auth: CodexAuth::from_api_key("dummy"), + pre_build_hooks: vec![], } } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 29cc3ffb19..e047899d72 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,7 @@ mod seatbelt; mod shell_command; mod shell_serialization; mod shell_snapshot; +mod skills; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs new file mode 100644 index 0000000000..d6ced3c1dc --- /dev/null +++ b/codex-rs/core/tests/suite/skills.rs @@ -0,0 +1,136 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use std::fs; +use std::path::Path; + +fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> std::path::PathBuf { + let skill_dir = home.join("skills").join(name); + fs::create_dir_all(&skill_dir).unwrap(); + let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); + let path = skill_dir.join("SKILL.md"); + fs::write(&path, contents).unwrap(); + path +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_includes_skill_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let skill_body = "skill body"; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + write_skill(home, "demo", "demo skill", skill_body); + }); + let test = builder.build(&server).await?; + + let skill_path = test.codex_home_path().join("skills/demo/SKILL.md"); + let skill_path = std::fs::canonicalize(skill_path)?; + + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Text { + text: "please use $demo".to_string(), + }, + UserInput::Skill { + name: "demo".to_string(), + path: skill_path.clone(), + }, + ], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }) + .await?; + + core_test_support::wait_for_event(test.codex.as_ref(), |event| { + matches!(event, codex_core::protocol::EventMsg::TaskComplete(_)) + }) + .await; + + let request = mock.single_request(); + let user_texts = request.message_input_texts("user"); + let skill_path_str = skill_path.to_string_lossy(); + assert!( + user_texts.iter().any(|text| { + text.contains("\ndemo") + && text.contains("") + && text.contains(skill_body) + && text.contains(skill_path_str.as_ref()) + }), + "expected skill instructions in user input, got {user_texts:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_load_errors_surface_in_session_configured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + let skill_dir = home.join("skills").join("broken"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap(); + }); + let test = builder.build(&server).await?; + + let SkillLoadOutcomeInfo { skills, errors } = test + .session_configured + .skill_load_outcome + .as_ref() + .expect("skill outcome present"); + + assert!( + skills.is_empty(), + "expected no skills loaded, got {skills:?}" + ); + assert_eq!(errors.len(), 1, "expected one load error"); + let error_path = errors[0].path.to_string_lossy(); + assert!( + error_path.ends_with("skills/broken/SKILL.md"), + "unexpected error path: {error_path}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2b3673f5a6..2291698d66 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,6 +85,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path, }), ); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 83ac25fdfd..3af472ddb9 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -266,6 +266,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; @@ -305,6 +306,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; let event = Event { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 51e977cb95..5c609c3c46 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -300,36 +300,37 @@ impl From> for ResponseInputItem { role: "user".to_string(), content: items .into_iter() - .map(|c| match c { - UserInput::Text { text } => ContentItem::InputText { text }, - UserInput::Image { image_url } => ContentItem::InputImage { image_url }, + .filter_map(|c| match c { + UserInput::Text { text } => Some(ContentItem::InputText { text }), + UserInput::Image { image_url } => Some(ContentItem::InputImage { image_url }), UserInput::LocalImage { path } => match load_and_resize_to_fit(&path) { - Ok(image) => ContentItem::InputImage { + Ok(image) => Some(ContentItem::InputImage { image_url: image.into_data_url(), - }, + }), Err(err) => { if matches!(&err, ImageProcessingError::Read { .. }) { - local_image_error_placeholder(&path, &err) + Some(local_image_error_placeholder(&path, &err)) } else if err.is_invalid_image() { - invalid_image_error_placeholder(&path, &err) + Some(invalid_image_error_placeholder(&path, &err)) } else { let Some(mime_guess) = mime_guess::from_path(&path).first() else { - return local_image_error_placeholder( + return Some(local_image_error_placeholder( &path, "unsupported MIME type (unknown)", - ); + )); }; let mime = mime_guess.essence_str().to_owned(); if !mime.starts_with("image/") { - return local_image_error_placeholder( + return Some(local_image_error_placeholder( &path, format!("unsupported MIME type `{mime}`"), - ); + )); } - unsupported_image_error_placeholder(&path, &mime) + Some(unsupported_image_error_placeholder(&path, &mime)) } } }, + UserInput::Skill { .. } => None, // Skill bodies are injected later in core }) .collect::>(), } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 973fd26582..73e2c9c877 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1624,6 +1624,25 @@ pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillInfo { + pub name: String, + pub description: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] +pub struct SkillLoadOutcomeInfo { + pub skills: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -1659,6 +1678,9 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_load_outcome: Option, + pub rollout_path: PathBuf, } @@ -1786,6 +1808,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 881b996514..26773e1a1a 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -21,4 +21,10 @@ pub enum UserInput { LocalImage { path: std::path::PathBuf, }, + + /// Skill selected by the user (name + path to SKILL.md). + Skill { + name: String, + path: std::path::PathBuf, + }, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1ce3b4fd51..a12393c91a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,6 +25,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; +#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; @@ -33,9 +34,9 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::TokenUsage; -use codex_core::skills::load_skills; -use codex_core::skills::model::SkillMetadata; +use codex_core::skills::SkillError; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -88,6 +89,17 @@ fn session_summary( }) } +fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .errors + .iter() + .map(|err| SkillError { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -237,8 +249,6 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, - - pub(crate) skills: Option>, } impl App { @@ -291,26 +301,6 @@ impl App { model = updated_model; } - let skills_outcome = load_skills(&config); - if !skills_outcome.errors.is_empty() { - match run_skill_error_prompt(tui, &skills_outcome.errors).await { - SkillErrorPromptOutcome::Exit => { - return Ok(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - }); - } - SkillErrorPromptOutcome::Continue => {} - } - } - - let skills = if config.features.enabled(Feature::Skills) { - Some(skills_outcome.skills.clone()) - } else { - None - }; - let enhanced_keys_supported = tui.enhanced_keys_supported(); let model_family = conversation_manager .get_models_manager() @@ -328,7 +318,6 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, model_family: model_family.clone(), }; @@ -355,7 +344,6 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, model_family: model_family.clone(), }; @@ -393,7 +381,6 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -519,7 +506,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, model_family: model_family.clone(), }; @@ -570,7 +556,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, model_family: model_family.clone(), }; @@ -662,6 +647,19 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } + if let EventMsg::SessionConfigured(cfg) = &event.msg + && let Some(outcome) = cfg.skill_load_outcome.as_ref() + && !outcome.errors.is_empty() + { + let errors = skill_errors_from_outcome(outcome); + match run_skill_error_prompt(tui, &errors).await { + SkillErrorPromptOutcome::Exit => { + self.chat_widget.submit_op(Op::Shutdown); + return Ok(false); + } + SkillErrorPromptOutcome::Continue => {} + } + } self.chat_widget.handle_codex_event(event); } AppEvent::ConversationHistory(ev) => { @@ -1209,7 +1207,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, } } @@ -1250,7 +1247,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, }, rx, op_rx, @@ -1358,6 +1354,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1413,6 +1410,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index deb629765a..671702d308 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -350,7 +350,6 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ed498e949c..39b600b155 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -801,6 +801,10 @@ impl ChatComposer { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } + pub fn skills(&self) -> Option<&Vec> { + self.skills.as_ref() + } + /// Extract a token prefixed with `prefix` under the cursor, if any. /// /// The returned string **does not** include the prefix. diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 554810de7f..8516687284 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -131,10 +131,19 @@ impl BottomPane { } } + pub fn set_skills(&mut self, skills: Option>) { + self.composer.set_skill_mentions(skills); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } + pub fn skills(&self) -> Option<&Vec> { + self.composer.skills() + } + #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option { self.context_window_percent diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ea29c00d93..6196e04621 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,6 +44,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -263,7 +264,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, - pub(crate) skills: Option>, pub(crate) is_first_run: bool, pub(crate) model_family: ModelFamily, } @@ -392,6 +392,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); + self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -416,6 +417,11 @@ impl ChatWidget { } } + fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { + let skills = outcome.map(skills_from_outcome); + self.bottom_pane.set_skills(skills); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1262,7 +1268,6 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, is_first_run, model_family, } = common; @@ -1285,7 +1290,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, config, @@ -1348,7 +1353,6 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, model_family, .. } = common; @@ -1371,7 +1375,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, config, @@ -1738,6 +1742,16 @@ impl ChatWidget { items.push(UserInput::LocalImage { path }); } + if let Some(skills) = self.bottom_pane.skills() { + let skill_mentions = find_skill_mentions(&text, skills); + for skill in skill_mentions { + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path.clone(), + }); + } + } + self.codex_op_tx .send(Op::UserInput { items }) .unwrap_or_else(|e| { @@ -3459,5 +3473,33 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect() +} + +fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut matches: Vec = Vec::new(); + for skill in skills { + if seen.contains(&skill.name) { + continue; + } + let needle = format!("${}", skill.name); + if text.contains(&needle) { + seen.insert(skill.name.clone()); + matches.push(skill.clone()); + } + } + matches +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c54f0da3d5..bd85a9edc8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -122,6 +122,7 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; @@ -364,7 +365,6 @@ async fn helpers_are_available_and_do_not_panic() { auth_manager, models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), - skills: None, is_first_run: true, model_family, }; From 321625072a384f124aceecb876f73f0e0f1e6a7c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 10 Dec 2025 14:01:18 -0800 Subject: [PATCH 132/159] Show the default model in model picker (#7838) See the snapshot --- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 10 +++++++--- codex-rs/tui/src/chatwidget.rs | 6 ++++-- ...i__chatwidget__tests__model_selection_popup.snap | 13 +++++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index d23fd8ed3b..46d6daac60 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -40,6 +40,7 @@ pub(crate) struct SelectionItem { pub description: Option, pub selected_description: Option, pub is_current: bool, + pub is_default: bool, pub actions: Vec, pub dismiss_on_select: bool, pub search_value: Option, @@ -187,11 +188,14 @@ impl ListSelectionView { let is_selected = self.state.selected_idx == Some(visible_idx); let prefix = if is_selected { '›' } else { ' ' }; let name = item.name.as_str(); - let name_with_marker = if item.is_current { - format!("{name} (current)") + let marker = if item.is_current { + " (current)" + } else if item.is_default { + " (default)" } else { - item.name.clone() + "" }; + let name_with_marker = format!("{name}{marker}"); let n = visible_idx + 1; let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6196e04621..82f00f9cac 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2240,9 +2240,10 @@ impl ChatWidget { Some(preset.default_reasoning_effort), ); SelectionItem { - name: preset.display_name, + name: preset.display_name.clone(), description, is_current: model == current_model, + is_default: preset.is_default, actions, dismiss_on_select: true, ..Default::default() @@ -2319,9 +2320,10 @@ impl ChatWidget { }); })]; items.push(SelectionItem { - name: preset.display_name.to_string(), + name: preset.display_name.clone(), description, is_current, + is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 56a209ef73..a7a1c56519 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -5,11 +5,12 @@ expression: popup Select Model and Effort Access legacy models by running codex -m or in your config.toml -› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast - reasoning. - 2. gpt-5.1-codex Optimized for codex. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.1 Broad world knowledge with strong general reasoning. +› 1. gpt-5.1-codex-max (default) Latest Codex-optimized flagship for deep and + fast reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but + less capable. + 4. gpt-5.1 Broad world knowledge with strong general + reasoning. Press enter to select reasoning effort, or esc to dismiss. From 90f262e9a46e592a58fe3e2cd6efc8717e448098 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 10 Dec 2025 14:53:46 -0800 Subject: [PATCH 133/159] feat(tui2): copy tui crate and normalize snapshots (#7833) Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames. --- codex-rs/Cargo.lock | 57 + codex-rs/cli/src/main.rs | 3 +- codex-rs/tui2/Cargo.toml | 91 +- codex-rs/tui2/frames/blocks/frame_1.txt | 17 + codex-rs/tui2/frames/blocks/frame_10.txt | 17 + codex-rs/tui2/frames/blocks/frame_11.txt | 17 + codex-rs/tui2/frames/blocks/frame_12.txt | 17 + codex-rs/tui2/frames/blocks/frame_13.txt | 17 + codex-rs/tui2/frames/blocks/frame_14.txt | 17 + codex-rs/tui2/frames/blocks/frame_15.txt | 17 + codex-rs/tui2/frames/blocks/frame_16.txt | 17 + codex-rs/tui2/frames/blocks/frame_17.txt | 17 + codex-rs/tui2/frames/blocks/frame_18.txt | 17 + codex-rs/tui2/frames/blocks/frame_19.txt | 17 + codex-rs/tui2/frames/blocks/frame_2.txt | 17 + codex-rs/tui2/frames/blocks/frame_20.txt | 17 + codex-rs/tui2/frames/blocks/frame_21.txt | 17 + codex-rs/tui2/frames/blocks/frame_22.txt | 17 + codex-rs/tui2/frames/blocks/frame_23.txt | 17 + codex-rs/tui2/frames/blocks/frame_24.txt | 17 + codex-rs/tui2/frames/blocks/frame_25.txt | 17 + codex-rs/tui2/frames/blocks/frame_26.txt | 17 + codex-rs/tui2/frames/blocks/frame_27.txt | 17 + codex-rs/tui2/frames/blocks/frame_28.txt | 17 + codex-rs/tui2/frames/blocks/frame_29.txt | 17 + codex-rs/tui2/frames/blocks/frame_3.txt | 17 + codex-rs/tui2/frames/blocks/frame_30.txt | 17 + codex-rs/tui2/frames/blocks/frame_31.txt | 17 + codex-rs/tui2/frames/blocks/frame_32.txt | 17 + codex-rs/tui2/frames/blocks/frame_33.txt | 17 + codex-rs/tui2/frames/blocks/frame_34.txt | 17 + codex-rs/tui2/frames/blocks/frame_35.txt | 17 + codex-rs/tui2/frames/blocks/frame_36.txt | 17 + codex-rs/tui2/frames/blocks/frame_4.txt | 17 + codex-rs/tui2/frames/blocks/frame_5.txt | 17 + codex-rs/tui2/frames/blocks/frame_6.txt | 17 + codex-rs/tui2/frames/blocks/frame_7.txt | 17 + codex-rs/tui2/frames/blocks/frame_8.txt | 17 + codex-rs/tui2/frames/blocks/frame_9.txt | 17 + codex-rs/tui2/frames/codex/frame_1.txt | 17 + codex-rs/tui2/frames/codex/frame_10.txt | 17 + codex-rs/tui2/frames/codex/frame_11.txt | 17 + codex-rs/tui2/frames/codex/frame_12.txt | 17 + codex-rs/tui2/frames/codex/frame_13.txt | 17 + codex-rs/tui2/frames/codex/frame_14.txt | 17 + codex-rs/tui2/frames/codex/frame_15.txt | 17 + codex-rs/tui2/frames/codex/frame_16.txt | 17 + codex-rs/tui2/frames/codex/frame_17.txt | 17 + codex-rs/tui2/frames/codex/frame_18.txt | 17 + codex-rs/tui2/frames/codex/frame_19.txt | 17 + codex-rs/tui2/frames/codex/frame_2.txt | 17 + codex-rs/tui2/frames/codex/frame_20.txt | 17 + codex-rs/tui2/frames/codex/frame_21.txt | 17 + codex-rs/tui2/frames/codex/frame_22.txt | 17 + codex-rs/tui2/frames/codex/frame_23.txt | 17 + codex-rs/tui2/frames/codex/frame_24.txt | 17 + codex-rs/tui2/frames/codex/frame_25.txt | 17 + codex-rs/tui2/frames/codex/frame_26.txt | 17 + codex-rs/tui2/frames/codex/frame_27.txt | 17 + codex-rs/tui2/frames/codex/frame_28.txt | 17 + codex-rs/tui2/frames/codex/frame_29.txt | 17 + codex-rs/tui2/frames/codex/frame_3.txt | 17 + codex-rs/tui2/frames/codex/frame_30.txt | 17 + codex-rs/tui2/frames/codex/frame_31.txt | 17 + codex-rs/tui2/frames/codex/frame_32.txt | 17 + codex-rs/tui2/frames/codex/frame_33.txt | 17 + codex-rs/tui2/frames/codex/frame_34.txt | 17 + codex-rs/tui2/frames/codex/frame_35.txt | 17 + codex-rs/tui2/frames/codex/frame_36.txt | 17 + codex-rs/tui2/frames/codex/frame_4.txt | 17 + codex-rs/tui2/frames/codex/frame_5.txt | 17 + codex-rs/tui2/frames/codex/frame_6.txt | 17 + codex-rs/tui2/frames/codex/frame_7.txt | 17 + codex-rs/tui2/frames/codex/frame_8.txt | 17 + codex-rs/tui2/frames/codex/frame_9.txt | 17 + codex-rs/tui2/frames/default/frame_1.txt | 17 + codex-rs/tui2/frames/default/frame_10.txt | 17 + codex-rs/tui2/frames/default/frame_11.txt | 17 + codex-rs/tui2/frames/default/frame_12.txt | 17 + codex-rs/tui2/frames/default/frame_13.txt | 17 + codex-rs/tui2/frames/default/frame_14.txt | 17 + codex-rs/tui2/frames/default/frame_15.txt | 17 + codex-rs/tui2/frames/default/frame_16.txt | 17 + codex-rs/tui2/frames/default/frame_17.txt | 17 + codex-rs/tui2/frames/default/frame_18.txt | 17 + codex-rs/tui2/frames/default/frame_19.txt | 17 + codex-rs/tui2/frames/default/frame_2.txt | 17 + codex-rs/tui2/frames/default/frame_20.txt | 17 + codex-rs/tui2/frames/default/frame_21.txt | 17 + codex-rs/tui2/frames/default/frame_22.txt | 17 + codex-rs/tui2/frames/default/frame_23.txt | 17 + codex-rs/tui2/frames/default/frame_24.txt | 17 + codex-rs/tui2/frames/default/frame_25.txt | 17 + codex-rs/tui2/frames/default/frame_26.txt | 17 + codex-rs/tui2/frames/default/frame_27.txt | 17 + codex-rs/tui2/frames/default/frame_28.txt | 17 + codex-rs/tui2/frames/default/frame_29.txt | 17 + codex-rs/tui2/frames/default/frame_3.txt | 17 + codex-rs/tui2/frames/default/frame_30.txt | 17 + codex-rs/tui2/frames/default/frame_31.txt | 17 + codex-rs/tui2/frames/default/frame_32.txt | 17 + codex-rs/tui2/frames/default/frame_33.txt | 17 + codex-rs/tui2/frames/default/frame_34.txt | 17 + codex-rs/tui2/frames/default/frame_35.txt | 17 + codex-rs/tui2/frames/default/frame_36.txt | 17 + codex-rs/tui2/frames/default/frame_4.txt | 17 + codex-rs/tui2/frames/default/frame_5.txt | 17 + codex-rs/tui2/frames/default/frame_6.txt | 17 + codex-rs/tui2/frames/default/frame_7.txt | 17 + codex-rs/tui2/frames/default/frame_8.txt | 17 + codex-rs/tui2/frames/default/frame_9.txt | 17 + codex-rs/tui2/frames/dots/frame_1.txt | 17 + codex-rs/tui2/frames/dots/frame_10.txt | 17 + codex-rs/tui2/frames/dots/frame_11.txt | 17 + codex-rs/tui2/frames/dots/frame_12.txt | 17 + codex-rs/tui2/frames/dots/frame_13.txt | 17 + codex-rs/tui2/frames/dots/frame_14.txt | 17 + codex-rs/tui2/frames/dots/frame_15.txt | 17 + codex-rs/tui2/frames/dots/frame_16.txt | 17 + codex-rs/tui2/frames/dots/frame_17.txt | 17 + codex-rs/tui2/frames/dots/frame_18.txt | 17 + codex-rs/tui2/frames/dots/frame_19.txt | 17 + codex-rs/tui2/frames/dots/frame_2.txt | 17 + codex-rs/tui2/frames/dots/frame_20.txt | 17 + codex-rs/tui2/frames/dots/frame_21.txt | 17 + codex-rs/tui2/frames/dots/frame_22.txt | 17 + codex-rs/tui2/frames/dots/frame_23.txt | 17 + codex-rs/tui2/frames/dots/frame_24.txt | 17 + codex-rs/tui2/frames/dots/frame_25.txt | 17 + codex-rs/tui2/frames/dots/frame_26.txt | 17 + codex-rs/tui2/frames/dots/frame_27.txt | 17 + codex-rs/tui2/frames/dots/frame_28.txt | 17 + codex-rs/tui2/frames/dots/frame_29.txt | 17 + codex-rs/tui2/frames/dots/frame_3.txt | 17 + codex-rs/tui2/frames/dots/frame_30.txt | 17 + codex-rs/tui2/frames/dots/frame_31.txt | 17 + codex-rs/tui2/frames/dots/frame_32.txt | 17 + codex-rs/tui2/frames/dots/frame_33.txt | 17 + codex-rs/tui2/frames/dots/frame_34.txt | 17 + codex-rs/tui2/frames/dots/frame_35.txt | 17 + codex-rs/tui2/frames/dots/frame_36.txt | 17 + codex-rs/tui2/frames/dots/frame_4.txt | 17 + codex-rs/tui2/frames/dots/frame_5.txt | 17 + codex-rs/tui2/frames/dots/frame_6.txt | 17 + codex-rs/tui2/frames/dots/frame_7.txt | 17 + codex-rs/tui2/frames/dots/frame_8.txt | 17 + codex-rs/tui2/frames/dots/frame_9.txt | 17 + codex-rs/tui2/frames/hash/frame_1.txt | 17 + codex-rs/tui2/frames/hash/frame_10.txt | 17 + codex-rs/tui2/frames/hash/frame_11.txt | 17 + codex-rs/tui2/frames/hash/frame_12.txt | 17 + codex-rs/tui2/frames/hash/frame_13.txt | 17 + codex-rs/tui2/frames/hash/frame_14.txt | 17 + codex-rs/tui2/frames/hash/frame_15.txt | 17 + codex-rs/tui2/frames/hash/frame_16.txt | 17 + codex-rs/tui2/frames/hash/frame_17.txt | 17 + codex-rs/tui2/frames/hash/frame_18.txt | 17 + codex-rs/tui2/frames/hash/frame_19.txt | 17 + codex-rs/tui2/frames/hash/frame_2.txt | 17 + codex-rs/tui2/frames/hash/frame_20.txt | 17 + codex-rs/tui2/frames/hash/frame_21.txt | 17 + codex-rs/tui2/frames/hash/frame_22.txt | 17 + codex-rs/tui2/frames/hash/frame_23.txt | 17 + codex-rs/tui2/frames/hash/frame_24.txt | 17 + codex-rs/tui2/frames/hash/frame_25.txt | 17 + codex-rs/tui2/frames/hash/frame_26.txt | 17 + codex-rs/tui2/frames/hash/frame_27.txt | 17 + codex-rs/tui2/frames/hash/frame_28.txt | 17 + codex-rs/tui2/frames/hash/frame_29.txt | 17 + codex-rs/tui2/frames/hash/frame_3.txt | 17 + codex-rs/tui2/frames/hash/frame_30.txt | 17 + codex-rs/tui2/frames/hash/frame_31.txt | 17 + codex-rs/tui2/frames/hash/frame_32.txt | 17 + codex-rs/tui2/frames/hash/frame_33.txt | 17 + codex-rs/tui2/frames/hash/frame_34.txt | 17 + codex-rs/tui2/frames/hash/frame_35.txt | 17 + codex-rs/tui2/frames/hash/frame_36.txt | 17 + codex-rs/tui2/frames/hash/frame_4.txt | 17 + codex-rs/tui2/frames/hash/frame_5.txt | 17 + codex-rs/tui2/frames/hash/frame_6.txt | 17 + codex-rs/tui2/frames/hash/frame_7.txt | 17 + codex-rs/tui2/frames/hash/frame_8.txt | 17 + codex-rs/tui2/frames/hash/frame_9.txt | 17 + codex-rs/tui2/frames/hbars/frame_1.txt | 17 + codex-rs/tui2/frames/hbars/frame_10.txt | 17 + codex-rs/tui2/frames/hbars/frame_11.txt | 17 + codex-rs/tui2/frames/hbars/frame_12.txt | 17 + codex-rs/tui2/frames/hbars/frame_13.txt | 17 + codex-rs/tui2/frames/hbars/frame_14.txt | 17 + codex-rs/tui2/frames/hbars/frame_15.txt | 17 + codex-rs/tui2/frames/hbars/frame_16.txt | 17 + codex-rs/tui2/frames/hbars/frame_17.txt | 17 + codex-rs/tui2/frames/hbars/frame_18.txt | 17 + codex-rs/tui2/frames/hbars/frame_19.txt | 17 + codex-rs/tui2/frames/hbars/frame_2.txt | 17 + codex-rs/tui2/frames/hbars/frame_20.txt | 17 + codex-rs/tui2/frames/hbars/frame_21.txt | 17 + codex-rs/tui2/frames/hbars/frame_22.txt | 17 + codex-rs/tui2/frames/hbars/frame_23.txt | 17 + codex-rs/tui2/frames/hbars/frame_24.txt | 17 + codex-rs/tui2/frames/hbars/frame_25.txt | 17 + codex-rs/tui2/frames/hbars/frame_26.txt | 17 + codex-rs/tui2/frames/hbars/frame_27.txt | 17 + codex-rs/tui2/frames/hbars/frame_28.txt | 17 + codex-rs/tui2/frames/hbars/frame_29.txt | 17 + codex-rs/tui2/frames/hbars/frame_3.txt | 17 + codex-rs/tui2/frames/hbars/frame_30.txt | 17 + codex-rs/tui2/frames/hbars/frame_31.txt | 17 + codex-rs/tui2/frames/hbars/frame_32.txt | 17 + codex-rs/tui2/frames/hbars/frame_33.txt | 17 + codex-rs/tui2/frames/hbars/frame_34.txt | 17 + codex-rs/tui2/frames/hbars/frame_35.txt | 17 + codex-rs/tui2/frames/hbars/frame_36.txt | 17 + codex-rs/tui2/frames/hbars/frame_4.txt | 17 + codex-rs/tui2/frames/hbars/frame_5.txt | 17 + codex-rs/tui2/frames/hbars/frame_6.txt | 17 + codex-rs/tui2/frames/hbars/frame_7.txt | 17 + codex-rs/tui2/frames/hbars/frame_8.txt | 17 + codex-rs/tui2/frames/hbars/frame_9.txt | 17 + codex-rs/tui2/frames/openai/frame_1.txt | 17 + codex-rs/tui2/frames/openai/frame_10.txt | 17 + codex-rs/tui2/frames/openai/frame_11.txt | 17 + codex-rs/tui2/frames/openai/frame_12.txt | 17 + codex-rs/tui2/frames/openai/frame_13.txt | 17 + codex-rs/tui2/frames/openai/frame_14.txt | 17 + codex-rs/tui2/frames/openai/frame_15.txt | 17 + codex-rs/tui2/frames/openai/frame_16.txt | 17 + codex-rs/tui2/frames/openai/frame_17.txt | 17 + codex-rs/tui2/frames/openai/frame_18.txt | 17 + codex-rs/tui2/frames/openai/frame_19.txt | 17 + codex-rs/tui2/frames/openai/frame_2.txt | 17 + codex-rs/tui2/frames/openai/frame_20.txt | 17 + codex-rs/tui2/frames/openai/frame_21.txt | 17 + codex-rs/tui2/frames/openai/frame_22.txt | 17 + codex-rs/tui2/frames/openai/frame_23.txt | 17 + codex-rs/tui2/frames/openai/frame_24.txt | 17 + codex-rs/tui2/frames/openai/frame_25.txt | 17 + codex-rs/tui2/frames/openai/frame_26.txt | 17 + codex-rs/tui2/frames/openai/frame_27.txt | 17 + codex-rs/tui2/frames/openai/frame_28.txt | 17 + codex-rs/tui2/frames/openai/frame_29.txt | 17 + codex-rs/tui2/frames/openai/frame_3.txt | 17 + codex-rs/tui2/frames/openai/frame_30.txt | 17 + codex-rs/tui2/frames/openai/frame_31.txt | 17 + codex-rs/tui2/frames/openai/frame_32.txt | 17 + codex-rs/tui2/frames/openai/frame_33.txt | 17 + codex-rs/tui2/frames/openai/frame_34.txt | 17 + codex-rs/tui2/frames/openai/frame_35.txt | 17 + codex-rs/tui2/frames/openai/frame_36.txt | 17 + codex-rs/tui2/frames/openai/frame_4.txt | 17 + codex-rs/tui2/frames/openai/frame_5.txt | 17 + codex-rs/tui2/frames/openai/frame_6.txt | 17 + codex-rs/tui2/frames/openai/frame_7.txt | 17 + codex-rs/tui2/frames/openai/frame_8.txt | 17 + codex-rs/tui2/frames/openai/frame_9.txt | 17 + codex-rs/tui2/frames/shapes/frame_1.txt | 17 + codex-rs/tui2/frames/shapes/frame_10.txt | 17 + codex-rs/tui2/frames/shapes/frame_11.txt | 17 + codex-rs/tui2/frames/shapes/frame_12.txt | 17 + codex-rs/tui2/frames/shapes/frame_13.txt | 17 + codex-rs/tui2/frames/shapes/frame_14.txt | 17 + codex-rs/tui2/frames/shapes/frame_15.txt | 17 + codex-rs/tui2/frames/shapes/frame_16.txt | 17 + codex-rs/tui2/frames/shapes/frame_17.txt | 17 + codex-rs/tui2/frames/shapes/frame_18.txt | 17 + codex-rs/tui2/frames/shapes/frame_19.txt | 17 + codex-rs/tui2/frames/shapes/frame_2.txt | 17 + codex-rs/tui2/frames/shapes/frame_20.txt | 17 + codex-rs/tui2/frames/shapes/frame_21.txt | 17 + codex-rs/tui2/frames/shapes/frame_22.txt | 17 + codex-rs/tui2/frames/shapes/frame_23.txt | 17 + codex-rs/tui2/frames/shapes/frame_24.txt | 17 + codex-rs/tui2/frames/shapes/frame_25.txt | 17 + codex-rs/tui2/frames/shapes/frame_26.txt | 17 + codex-rs/tui2/frames/shapes/frame_27.txt | 17 + codex-rs/tui2/frames/shapes/frame_28.txt | 17 + codex-rs/tui2/frames/shapes/frame_29.txt | 17 + codex-rs/tui2/frames/shapes/frame_3.txt | 17 + codex-rs/tui2/frames/shapes/frame_30.txt | 17 + codex-rs/tui2/frames/shapes/frame_31.txt | 17 + codex-rs/tui2/frames/shapes/frame_32.txt | 17 + codex-rs/tui2/frames/shapes/frame_33.txt | 17 + codex-rs/tui2/frames/shapes/frame_34.txt | 17 + codex-rs/tui2/frames/shapes/frame_35.txt | 17 + codex-rs/tui2/frames/shapes/frame_36.txt | 17 + codex-rs/tui2/frames/shapes/frame_4.txt | 17 + codex-rs/tui2/frames/shapes/frame_5.txt | 17 + codex-rs/tui2/frames/shapes/frame_6.txt | 17 + codex-rs/tui2/frames/shapes/frame_7.txt | 17 + codex-rs/tui2/frames/shapes/frame_8.txt | 17 + codex-rs/tui2/frames/shapes/frame_9.txt | 17 + codex-rs/tui2/frames/slug/frame_1.txt | 17 + codex-rs/tui2/frames/slug/frame_10.txt | 17 + codex-rs/tui2/frames/slug/frame_11.txt | 17 + codex-rs/tui2/frames/slug/frame_12.txt | 17 + codex-rs/tui2/frames/slug/frame_13.txt | 17 + codex-rs/tui2/frames/slug/frame_14.txt | 17 + codex-rs/tui2/frames/slug/frame_15.txt | 17 + codex-rs/tui2/frames/slug/frame_16.txt | 17 + codex-rs/tui2/frames/slug/frame_17.txt | 17 + codex-rs/tui2/frames/slug/frame_18.txt | 17 + codex-rs/tui2/frames/slug/frame_19.txt | 17 + codex-rs/tui2/frames/slug/frame_2.txt | 17 + codex-rs/tui2/frames/slug/frame_20.txt | 17 + codex-rs/tui2/frames/slug/frame_21.txt | 17 + codex-rs/tui2/frames/slug/frame_22.txt | 17 + codex-rs/tui2/frames/slug/frame_23.txt | 17 + codex-rs/tui2/frames/slug/frame_24.txt | 17 + codex-rs/tui2/frames/slug/frame_25.txt | 17 + codex-rs/tui2/frames/slug/frame_26.txt | 17 + codex-rs/tui2/frames/slug/frame_27.txt | 17 + codex-rs/tui2/frames/slug/frame_28.txt | 17 + codex-rs/tui2/frames/slug/frame_29.txt | 17 + codex-rs/tui2/frames/slug/frame_3.txt | 17 + codex-rs/tui2/frames/slug/frame_30.txt | 17 + codex-rs/tui2/frames/slug/frame_31.txt | 17 + codex-rs/tui2/frames/slug/frame_32.txt | 17 + codex-rs/tui2/frames/slug/frame_33.txt | 17 + codex-rs/tui2/frames/slug/frame_34.txt | 17 + codex-rs/tui2/frames/slug/frame_35.txt | 17 + codex-rs/tui2/frames/slug/frame_36.txt | 17 + codex-rs/tui2/frames/slug/frame_4.txt | 17 + codex-rs/tui2/frames/slug/frame_5.txt | 17 + codex-rs/tui2/frames/slug/frame_6.txt | 17 + codex-rs/tui2/frames/slug/frame_7.txt | 17 + codex-rs/tui2/frames/slug/frame_8.txt | 17 + codex-rs/tui2/frames/slug/frame_9.txt | 17 + codex-rs/tui2/frames/vbars/frame_1.txt | 17 + codex-rs/tui2/frames/vbars/frame_10.txt | 17 + codex-rs/tui2/frames/vbars/frame_11.txt | 17 + codex-rs/tui2/frames/vbars/frame_12.txt | 17 + codex-rs/tui2/frames/vbars/frame_13.txt | 17 + codex-rs/tui2/frames/vbars/frame_14.txt | 17 + codex-rs/tui2/frames/vbars/frame_15.txt | 17 + codex-rs/tui2/frames/vbars/frame_16.txt | 17 + codex-rs/tui2/frames/vbars/frame_17.txt | 17 + codex-rs/tui2/frames/vbars/frame_18.txt | 17 + codex-rs/tui2/frames/vbars/frame_19.txt | 17 + codex-rs/tui2/frames/vbars/frame_2.txt | 17 + codex-rs/tui2/frames/vbars/frame_20.txt | 17 + codex-rs/tui2/frames/vbars/frame_21.txt | 17 + codex-rs/tui2/frames/vbars/frame_22.txt | 17 + codex-rs/tui2/frames/vbars/frame_23.txt | 17 + codex-rs/tui2/frames/vbars/frame_24.txt | 17 + codex-rs/tui2/frames/vbars/frame_25.txt | 17 + codex-rs/tui2/frames/vbars/frame_26.txt | 17 + codex-rs/tui2/frames/vbars/frame_27.txt | 17 + codex-rs/tui2/frames/vbars/frame_28.txt | 17 + codex-rs/tui2/frames/vbars/frame_29.txt | 17 + codex-rs/tui2/frames/vbars/frame_3.txt | 17 + codex-rs/tui2/frames/vbars/frame_30.txt | 17 + codex-rs/tui2/frames/vbars/frame_31.txt | 17 + codex-rs/tui2/frames/vbars/frame_32.txt | 17 + codex-rs/tui2/frames/vbars/frame_33.txt | 17 + codex-rs/tui2/frames/vbars/frame_34.txt | 17 + codex-rs/tui2/frames/vbars/frame_35.txt | 17 + codex-rs/tui2/frames/vbars/frame_36.txt | 17 + codex-rs/tui2/frames/vbars/frame_4.txt | 17 + codex-rs/tui2/frames/vbars/frame_5.txt | 17 + codex-rs/tui2/frames/vbars/frame_6.txt | 17 + codex-rs/tui2/frames/vbars/frame_7.txt | 17 + codex-rs/tui2/frames/vbars/frame_8.txt | 17 + codex-rs/tui2/frames/vbars/frame_9.txt | 17 + codex-rs/tui2/prompt_for_init_command.md | 40 + codex-rs/tui2/src/additional_dirs.rs | 71 + codex-rs/tui2/src/app.rs | 1510 +++++++ codex-rs/tui2/src/app_backtrack.rs | 518 +++ codex-rs/tui2/src/app_event.rs | 185 + codex-rs/tui2/src/app_event_sender.rs | 28 + codex-rs/tui2/src/ascii_animation.rs | 111 + codex-rs/tui2/src/bin/md-events2.rs | 15 + .../tui2/src/bottom_pane/approval_overlay.rs | 717 +++ .../tui2/src/bottom_pane/bottom_pane_view.rs | 37 + .../tui2/src/bottom_pane/chat_composer.rs | 3990 +++++++++++++++++ .../src/bottom_pane/chat_composer_history.rs | 300 ++ .../tui2/src/bottom_pane/command_popup.rs | 376 ++ .../src/bottom_pane/custom_prompt_view.rs | 247 + .../tui2/src/bottom_pane/feedback_view.rs | 559 +++ .../tui2/src/bottom_pane/file_search_popup.rs | 154 + codex-rs/tui2/src/bottom_pane/footer.rs | 530 +++ .../src/bottom_pane/list_selection_view.rs | 794 ++++ codex-rs/tui2/src/bottom_pane/mod.rs | 814 ++++ codex-rs/tui2/src/bottom_pane/paste_burst.rs | 267 ++ codex-rs/tui2/src/bottom_pane/popup_consts.rs | 21 + codex-rs/tui2/src/bottom_pane/prompt_args.rs | 406 ++ .../src/bottom_pane/queued_user_messages.rs | 157 + codex-rs/tui2/src/bottom_pane/scroll_state.rs | 115 + .../src/bottom_pane/selection_popup_common.rs | 269 ++ codex-rs/tui2/src/bottom_pane/skill_popup.rs | 142 + ...mposer__tests__backspace_after_pastes.snap | 14 + ...tom_pane__chat_composer__tests__empty.snap | 14 + ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 + ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 + ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 + ...tests__footer_mode_esc_hint_backtrack.snap | 13 + ...ts__footer_mode_esc_hint_from_overlay.snap | 13 + ...ests__footer_mode_hidden_while_typing.snap | 13 + ...r_mode_overlay_then_external_esc_hint.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 16 + ...tom_pane__chat_composer__tests__large.snap | 14 + ...chat_composer__tests__multiple_pastes.snap | 14 + ..._chat_composer__tests__slash_popup_mo.snap | 9 + ...chat_composer__tests__slash_popup_res.snap | 10 + ...tom_pane__chat_composer__tests__small.snap | 14 + ...view__tests__feedback_view_bad_result.snap | 9 + ...edback_view__tests__feedback_view_bug.snap | 9 + ...iew__tests__feedback_view_good_result.snap | 9 + ...back_view__tests__feedback_view_other.snap | 9 + ...er__tests__footer_context_tokens_used.snap | 5 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 5 + ...er__tests__footer_ctrl_c_quit_running.snap | 5 + ...__footer__tests__footer_esc_hint_idle.snap | 5 + ...footer__tests__footer_esc_hint_primed.snap | 5 + ...sts__footer_shortcuts_context_running.snap | 5 + ...oter__tests__footer_shortcuts_default.snap | 5 + ...tests__footer_shortcuts_shift_and_esc.snap | 8 + ..._list_selection_model_picker_width_80.snap | 13 + ...selection_narrow_width_preserves_rows.snap | 16 + ..._list_selection_spacing_with_subtitle.snap | 12 + ...st_selection_spacing_without_subtitle.snap | 11 + ...ages__tests__render_many_line_message.snap | 27 + ...ests__render_more_than_three_messages.snap | 30 + ...r_messages__tests__render_one_message.snap | 18 + ..._messages__tests__render_two_messages.snap | 22 + ...ssages__tests__render_wrapped_message.snap | 25 + ...s_visible_when_status_hidden_snapshot.snap | 11 + ...er_fill_height_without_bottom_padding.snap | 10 + ...__status_and_queued_messages_snapshot.snap | 12 + ...mposer__tests__backspace_after_pastes.snap | 14 + ...tom_pane__chat_composer__tests__empty.snap | 14 + ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 + ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 + ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 + ...tests__footer_mode_esc_hint_backtrack.snap | 13 + ...ts__footer_mode_esc_hint_from_overlay.snap | 13 + ...ests__footer_mode_hidden_while_typing.snap | 13 + ...r_mode_overlay_then_external_esc_hint.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 16 + ...tom_pane__chat_composer__tests__large.snap | 14 + ...chat_composer__tests__multiple_pastes.snap | 14 + ..._chat_composer__tests__slash_popup_mo.snap | 9 + ...chat_composer__tests__slash_popup_res.snap | 11 + ...tom_pane__chat_composer__tests__small.snap | 14 + ...view__tests__feedback_view_bad_result.snap | 9 + ...edback_view__tests__feedback_view_bug.snap | 9 + ...iew__tests__feedback_view_good_result.snap | 9 + ...back_view__tests__feedback_view_other.snap | 9 + ...ack_view__tests__feedback_view_render.snap | 17 + ...er__tests__footer_context_tokens_used.snap | 5 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 5 + ...er__tests__footer_ctrl_c_quit_running.snap | 5 + ...__footer__tests__footer_esc_hint_idle.snap | 5 + ...footer__tests__footer_esc_hint_primed.snap | 5 + ...sts__footer_shortcuts_context_running.snap | 5 + ...oter__tests__footer_shortcuts_default.snap | 5 + ...tests__footer_shortcuts_shift_and_esc.snap | 8 + ..._list_selection_model_picker_width_80.snap | 13 + ...selection_narrow_width_preserves_rows.snap | 16 + ..._list_selection_spacing_with_subtitle.snap | 12 + ...st_selection_spacing_without_subtitle.snap | 11 + ...ueue__tests__render_many_line_message.snap | 27 + ...sage_queue__tests__render_one_message.snap | 18 + ...age_queue__tests__render_two_messages.snap | 22 + ..._queue__tests__render_wrapped_message.snap | 25 + ...ages__tests__render_many_line_message.snap | 27 + ...ests__render_more_than_three_messages.snap | 30 + ...r_messages__tests__render_one_message.snap | 18 + ..._messages__tests__render_two_messages.snap | 22 + ...ssages__tests__render_wrapped_message.snap | 25 + ...s_visible_when_status_hidden_snapshot.snap | 11 + ...er_fill_height_without_bottom_padding.snap | 10 + ...__status_and_queued_messages_snapshot.snap | 12 + ...hidden_when_height_too_small_height_1.snap | 5 + codex-rs/tui2/src/bottom_pane/textarea.rs | 2015 +++++++++ codex-rs/tui2/src/chatwidget.rs | 3463 ++++++++++++++ codex-rs/tui2/src/chatwidget/agent.rs | 108 + codex-rs/tui2/src/chatwidget/interrupts.rs | 96 + .../tui2/src/chatwidget/session_header.rs | 16 + ...ly_patch_manual_flow_history_approved.snap | 6 + ...hatwidget__tests__approval_modal_exec.snap | 15 + ..._tests__approval_modal_exec_no_reason.snap | 13 + ...atwidget__tests__approval_modal_patch.snap | 17 + ...get__tests__approvals_selection_popup.snap | 13 + ...ts__approvals_selection_popup@windows.snap | 14 + ...chatwidget__tests__chat_small_idle_h1.snap | 5 + ...chatwidget__tests__chat_small_idle_h2.snap | 6 + ...chatwidget__tests__chat_small_idle_h3.snap | 7 + ...twidget__tests__chat_small_running_h1.snap | 5 + ...twidget__tests__chat_small_running_h2.snap | 6 + ...twidget__tests__chat_small_running_h3.snap | 7 + ...exec_and_status_layout_vt100_snapshot.snap | 17 + ...t_markdown_code_blocks_vt100_snapshot.snap | 18 + ...2__chatwidget__tests__chatwidget_tall.snap | 27 + ...e_final_message_are_rendered_snapshot.snap | 5 + ...h_command_while_task_running_snapshot.snap | 5 + ...pproval_history_decision_aborted_long.snap | 6 + ...al_history_decision_aborted_multiline.snap | 5 + ...roval_history_decision_approved_short.snap | 5 + ...dget__tests__exec_approval_modal_exec.snap | 36 + ...dget__tests__exploring_step1_start_ls.snap | 6 + ...get__tests__exploring_step2_finish_ls.snap | 6 + ..._tests__exploring_step3_start_cat_foo.snap | 7 + ...tests__exploring_step4_finish_cat_foo.snap | 7 + ...sts__exploring_step5_finish_sed_range.snap | 7 + ...tests__exploring_step6_finish_cat_bar.snap | 7 + ...dget__tests__feedback_selection_popup.snap | 11 + ..._tests__feedback_upload_consent_popup.snap | 14 + ...n_message_without_deltas_are_rendered.snap | 5 + ...tests__full_access_confirmation_popup.snap | 15 + ...t__tests__interrupt_exec_marks_failed.snap | 6 + ...tests__interrupted_turn_error_message.snap | 5 + ...cal_image_attachment_history_snapshot.snap | 6 + ...ests__model_reasoning_selection_popup.snap | 12 + ...ng_selection_popup_extra_high_warning.snap | 15 + ...twidget__tests__model_selection_popup.snap | 15 + ...tests__rate_limit_switch_prompt_popup.snap | 14 + ...atwidget__tests__status_widget_active.snap | 11 + ...sts__status_widget_and_approval_modal.snap | 17 + ...atwidget__tests__user_shell_ls_output.snap | 7 + ...ly_patch_manual_flow_history_approved.snap | 6 + ...hatwidget__tests__approval_modal_exec.snap | 17 + ..._tests__approval_modal_exec_no_reason.snap | 13 + ...atwidget__tests__approval_modal_patch.snap | 17 + ...get__tests__approvals_selection_popup.snap | 13 + ...ts__approvals_selection_popup@windows.snap | 13 + ...et__tests__binary_size_ideal_response.snap | 153 + ...chatwidget__tests__chat_small_idle_h1.snap | 5 + ...chatwidget__tests__chat_small_idle_h2.snap | 6 + ...chatwidget__tests__chat_small_idle_h3.snap | 7 + ...twidget__tests__chat_small_running_h1.snap | 5 + ...twidget__tests__chat_small_running_h2.snap | 6 + ...twidget__tests__chat_small_running_h3.snap | 7 + ...exec_and_status_layout_vt100_snapshot.snap | 17 + ...t_markdown_code_blocks_vt100_snapshot.snap | 18 + ...i__chatwidget__tests__chatwidget_tall.snap | 27 + ...e_final_message_are_rendered_snapshot.snap | 5 + ...h_command_while_task_running_snapshot.snap | 5 + ...pproval_history_decision_aborted_long.snap | 7 + ...al_history_decision_aborted_multiline.snap | 6 + ...roval_history_decision_approved_short.snap | 5 + ...dget__tests__exec_approval_modal_exec.snap | 36 + ...dget__tests__exploring_step1_start_ls.snap | 6 + ...get__tests__exploring_step2_finish_ls.snap | 6 + ..._tests__exploring_step3_start_cat_foo.snap | 7 + ...tests__exploring_step4_finish_cat_foo.snap | 7 + ...sts__exploring_step5_finish_sed_range.snap | 7 + ...tests__exploring_step6_finish_cat_bar.snap | 7 + ...dget__tests__feedback_selection_popup.snap | 11 + ..._tests__feedback_upload_consent_popup.snap | 14 + ...n_message_without_deltas_are_rendered.snap | 5 + ...tests__full_access_confirmation_popup.snap | 15 + ...t__tests__interrupt_exec_marks_failed.snap | 6 + ...tests__interrupted_turn_error_message.snap | 5 + ...cal_image_attachment_history_snapshot.snap | 6 + ...ests__model_reasoning_selection_popup.snap | 12 + ...ng_selection_popup_extra_high_warning.snap | 16 + ...twidget__tests__model_selection_popup.snap | 15 + ...tests__rate_limit_switch_prompt_popup.snap | 14 + ...atwidget__tests__status_widget_active.snap | 12 + ...sts__status_widget_and_approval_modal.snap | 17 + ..._tui__chatwidget__tests__update_popup.snap | 14 + ...atwidget__tests__user_shell_ls_output.snap | 7 + codex-rs/tui2/src/chatwidget/tests.rs | 3329 ++++++++++++++ codex-rs/tui2/src/cli.rs | 115 + codex-rs/tui2/src/clipboard_paste.rs | 504 +++ codex-rs/tui2/src/color.rs | 75 + codex-rs/tui2/src/custom_terminal.rs | 645 +++ codex-rs/tui2/src/diff_render.rs | 673 +++ codex-rs/tui2/src/exec_cell/mod.rs | 12 + codex-rs/tui2/src/exec_cell/model.rs | 150 + codex-rs/tui2/src/exec_cell/render.rs | 705 +++ codex-rs/tui2/src/exec_command.rs | 70 + codex-rs/tui2/src/file_search.rs | 199 + codex-rs/tui2/src/frames.rs | 71 + codex-rs/tui2/src/get_git_diff.rs | 119 + codex-rs/tui2/src/history_cell.rs | 2435 ++++++++++ codex-rs/tui2/src/insert_history.rs | 530 +++ codex-rs/tui2/src/key_hint.rs | 112 + codex-rs/tui2/src/lib.rs | 695 ++- codex-rs/tui2/src/live_wrap.rs | 290 ++ codex-rs/tui2/src/main.rs | 3 +- codex-rs/tui2/src/markdown.rs | 105 + codex-rs/tui2/src/markdown_render.rs | 678 +++ codex-rs/tui2/src/markdown_render_tests.rs | 995 ++++ codex-rs/tui2/src/markdown_stream.rs | 670 +++ codex-rs/tui2/src/model_migration.rs | 458 ++ codex-rs/tui2/src/onboarding/auth.rs | 709 +++ codex-rs/tui2/src/onboarding/mod.rs | 5 + .../tui2/src/onboarding/onboarding_screen.rs | 430 ++ ..._tests__renders_snapshot_for_git_repo.snap | 14 + ..._tests__renders_snapshot_for_git_repo.snap | 14 + .../tui2/src/onboarding/trust_directory.rs | 238 + codex-rs/tui2/src/onboarding/welcome.rs | 153 + codex-rs/tui2/src/oss_selection.rs | 369 ++ codex-rs/tui2/src/pager_overlay.rs | 1037 +++++ .../tui2/src/public_widgets/composer_input.rs | 128 + codex-rs/tui2/src/public_widgets/mod.rs | 1 + codex-rs/tui2/src/render/highlight.rs | 236 + codex-rs/tui2/src/render/line_utils.rs | 59 + codex-rs/tui2/src/render/mod.rs | 50 + codex-rs/tui2/src/render/renderable.rs | 431 ++ codex-rs/tui2/src/resume_picker.rs | 1728 +++++++ codex-rs/tui2/src/selection_list.rs | 35 + codex-rs/tui2/src/session_log.rs | 210 + codex-rs/tui2/src/shimmer.rs | 80 + codex-rs/tui2/src/skill_error_prompt.rs | 164 + codex-rs/tui2/src/slash_command.rs | 106 + ...__diff_render__tests__apply_add_block.snap | 14 + ...iff_render__tests__apply_delete_block.snap | 16 + ...er__tests__apply_multiple_files_block.snap | 18 + ...iff_render__tests__apply_update_block.snap | 16 + ..._block_line_numbers_three_digits_text.snap | 13 + ...__apply_update_block_relativizes_path.snap | 14 + ...__apply_update_block_wraps_long_lines.snap | 16 + ...ly_update_block_wraps_long_lines_text.snap | 15 + ...tests__apply_update_with_rename_block.snap | 16 + ...f_render__tests__wrap_behavior_insert.snap | 12 + ..._tests__active_mcp_tool_call_snapshot.snap | 5 + ...__tests__coalesced_reads_dedupe_names.snap | 6 + ...coalesces_reads_across_multiple_calls.snap | 7 + ...sces_sequential_reads_within_one_call.snap | 8 + ...ompleted_mcp_tool_call_error_snapshot.snap | 6 + ...call_multiple_outputs_inline_snapshot.snap | 7 + ...p_tool_call_multiple_outputs_snapshot.snap | 10 + ...pleted_mcp_tool_call_success_snapshot.snap | 6 + ...cp_tool_call_wrapped_outputs_snapshot.snap | 13 + ...p_tools_output_masks_sensitive_values.snap | 26 + ...both_lines_wrap_with_correct_prefixes.snap | 9 + ...ut_wrap_uses_branch_then_eight_spaces.snap | 7 + ...with_extra_indent_on_subsequent_lines.snap | 8 + ...pdate_with_note_and_wrapping_snapshot.snap | 20 + ...ts__plan_update_without_note_snapshot.snap | 7 + ...n_cell_multiline_with_stderr_snapshot.snap | 12 + ...single_line_command_compact_when_fits.snap | 6 + ...nd_wraps_with_four_space_continuation.snap | 8 + ...rr_tail_more_than_five_lines_snapshot.snap | 10 + ...wraps_and_prefixes_each_line_snapshot.snap | 8 + ...sts__markdown_render_complex_snapshot.snap | 62 + ...ration__tests__model_migration_prompt.snap | 19 + ...ts__model_migration_prompt_gpt5_codex.snap | 15 + ...odel_migration_prompt_gpt5_codex_mini.snap | 15 + ...s__model_migration_prompt_gpt5_family.snap | 15 + ..._tests__static_overlay_snapshot_basic.snap | 14 + ...ests__static_overlay_wraps_long_lines.snap | 12 + ...ript_overlay_apply_patch_scroll_vt100.snap | 15 + ...ts__transcript_overlay_snapshot_basic.snap | 14 + ...e_picker__tests__resume_picker_screen.snap | 13 + ...me_picker__tests__resume_picker_table.snap | 8 + ...ator_widget__tests__renders_truncated.snap | 6 + ...t__tests__renders_with_working_header.snap | 6 + ..._tui__diff_render__tests__add_details.snap | 15 + ...__diff_render__tests__apply_add_block.snap | 14 + ...iff_render__tests__apply_delete_block.snap | 16 + ...er__tests__apply_multiple_files_block.snap | 18 + ...iff_render__tests__apply_update_block.snap | 16 + ..._block_line_numbers_three_digits_text.snap | 13 + ...__apply_update_block_relativizes_path.snap | 14 + ...__apply_update_block_wraps_long_lines.snap | 16 + ...ly_update_block_wraps_long_lines_text.snap | 15 + ...tests__apply_update_with_rename_block.snap | 16 + ...iff_render__tests__blank_context_line.snap | 15 + ...tests__single_line_replacement_counts.snap | 13 + ...er__tests__update_details_with_rename.snap | 17 + ...ests__vertical_ellipsis_between_hunks.snap | 21 + ...f_render__tests__wrap_behavior_insert.snap | 12 + ..._tests__active_mcp_tool_call_snapshot.snap | 6 + ...__tests__coalesced_reads_dedupe_names.snap | 6 + ...coalesces_reads_across_multiple_calls.snap | 7 + ...sces_sequential_reads_within_one_call.snap | 8 + ...ompleted_mcp_tool_call_error_snapshot.snap | 7 + ...call_multiple_outputs_inline_snapshot.snap | 8 + ...p_tool_call_multiple_outputs_snapshot.snap | 11 + ...pleted_mcp_tool_call_success_snapshot.snap | 7 + ...cp_tool_call_wrapped_outputs_snapshot.snap | 14 + ...p_tools_output_masks_sensitive_values.snap | 27 + ...both_lines_wrap_with_correct_prefixes.snap | 9 + ...ut_wrap_uses_branch_then_eight_spaces.snap | 7 + ...with_extra_indent_on_subsequent_lines.snap | 8 + ...pdate_with_note_and_wrapping_snapshot.snap | 20 + ...ts__plan_update_without_note_snapshot.snap | 7 + ...n_cell_multiline_with_stderr_snapshot.snap | 12 + ...single_line_command_compact_when_fits.snap | 6 + ...nd_wraps_with_four_space_continuation.snap | 8 + ...rr_tail_more_than_five_lines_snapshot.snap | 10 + ...wraps_and_prefixes_each_line_snapshot.snap | 8 + ...sts__markdown_render_complex_snapshot.snap | 62 + ...ration__tests__model_migration_prompt.snap | 19 + ...ts__model_migration_prompt_gpt5_codex.snap | 15 + ...odel_migration_prompt_gpt5_codex_mini.snap | 15 + ...s__model_migration_prompt_gpt5_family.snap | 15 + ..._tests__static_overlay_snapshot_basic.snap | 14 + ...ests__static_overlay_wraps_long_lines.snap | 13 + ...ript_overlay_apply_patch_scroll_vt100.snap | 15 + ...ts__transcript_overlay_snapshot_basic.snap | 14 + ...e_picker__tests__resume_picker_screen.snap | 14 + ...me_picker__tests__resume_picker_table.snap | 8 + ...ator_widget__tests__renders_truncated.snap | 6 + ...__tests__renders_with_queued_messages.snap | 12 + ...s__renders_with_queued_messages@macos.snap | 13 + ...t__tests__renders_with_working_header.snap | 6 + ...te_prompt__tests__update_prompt_modal.snap | 13 + codex-rs/tui2/src/status/account.rs | 8 + codex-rs/tui2/src/status/card.rs | 409 ++ codex-rs/tui2/src/status/format.rs | 147 + codex-rs/tui2/src/status/helpers.rs | 189 + codex-rs/tui2/src/status/mod.rs | 13 + codex-rs/tui2/src/status/rate_limits.rs | 235 + ...ched_limits_hide_credits_without_flag.snap | 24 + ..._snapshot_includes_credits_and_limits.snap | 24 + ...tatus_snapshot_includes_monthly_limit.snap | 22 + ...s_snapshot_includes_reasoning_details.snap | 23 + ...s_snapshot_shows_empty_limits_message.snap | 22 + ...snapshot_shows_missing_limits_message.snap | 22 + ...s_snapshot_shows_stale_limits_message.snap | 24 + ...snapshot_truncates_in_narrow_terminal.snap | 22 + ...ched_limits_hide_credits_without_flag.snap | 24 + ..._snapshot_includes_credits_and_limits.snap | 24 + ...tatus_snapshot_includes_monthly_limit.snap | 22 + ...s_snapshot_includes_reasoning_details.snap | 23 + ...s_snapshot_shows_empty_limits_message.snap | 22 + ...snapshot_shows_missing_limits_message.snap | 22 + ...s_snapshot_shows_stale_limits_message.snap | 24 + ...snapshot_truncates_in_narrow_terminal.snap | 22 + codex-rs/tui2/src/status/tests.rs | 832 ++++ codex-rs/tui2/src/status_indicator_widget.rs | 253 ++ codex-rs/tui2/src/streaming/controller.rs | 223 + codex-rs/tui2/src/streaming/mod.rs | 39 + codex-rs/tui2/src/style.rs | 28 + codex-rs/tui2/src/terminal_palette.rs | 401 ++ codex-rs/tui2/src/test_backend.rs | 124 + codex-rs/tui2/src/text_formatting.rs | 525 +++ codex-rs/tui2/src/tooltips.rs | 49 + codex-rs/tui2/src/tui.rs | 441 ++ codex-rs/tui2/src/tui/frame_requester.rs | 249 + codex-rs/tui2/src/tui/job_control.rs | 182 + codex-rs/tui2/src/ui_consts.rs | 11 + codex-rs/tui2/src/update_action.rs | 115 + codex-rs/tui2/src/update_prompt.rs | 313 ++ codex-rs/tui2/src/updates.rs | 237 + codex-rs/tui2/src/version.rs | 2 + codex-rs/tui2/src/wrapping.rs | 652 +++ codex-rs/tui2/tooltips.txt | 11 + 742 files changed, 53559 insertions(+), 19 deletions(-) create mode 100644 codex-rs/tui2/frames/blocks/frame_1.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_10.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_11.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_12.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_13.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_14.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_15.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_16.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_17.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_18.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_19.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_2.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_20.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_21.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_22.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_23.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_24.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_25.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_26.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_27.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_28.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_29.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_3.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_30.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_31.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_32.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_33.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_34.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_35.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_36.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_4.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_5.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_6.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_7.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_8.txt create mode 100644 codex-rs/tui2/frames/blocks/frame_9.txt create mode 100644 codex-rs/tui2/frames/codex/frame_1.txt create mode 100644 codex-rs/tui2/frames/codex/frame_10.txt create mode 100644 codex-rs/tui2/frames/codex/frame_11.txt create mode 100644 codex-rs/tui2/frames/codex/frame_12.txt create mode 100644 codex-rs/tui2/frames/codex/frame_13.txt create mode 100644 codex-rs/tui2/frames/codex/frame_14.txt create mode 100644 codex-rs/tui2/frames/codex/frame_15.txt create mode 100644 codex-rs/tui2/frames/codex/frame_16.txt create mode 100644 codex-rs/tui2/frames/codex/frame_17.txt create mode 100644 codex-rs/tui2/frames/codex/frame_18.txt create mode 100644 codex-rs/tui2/frames/codex/frame_19.txt create mode 100644 codex-rs/tui2/frames/codex/frame_2.txt create mode 100644 codex-rs/tui2/frames/codex/frame_20.txt create mode 100644 codex-rs/tui2/frames/codex/frame_21.txt create mode 100644 codex-rs/tui2/frames/codex/frame_22.txt create mode 100644 codex-rs/tui2/frames/codex/frame_23.txt create mode 100644 codex-rs/tui2/frames/codex/frame_24.txt create mode 100644 codex-rs/tui2/frames/codex/frame_25.txt create mode 100644 codex-rs/tui2/frames/codex/frame_26.txt create mode 100644 codex-rs/tui2/frames/codex/frame_27.txt create mode 100644 codex-rs/tui2/frames/codex/frame_28.txt create mode 100644 codex-rs/tui2/frames/codex/frame_29.txt create mode 100644 codex-rs/tui2/frames/codex/frame_3.txt create mode 100644 codex-rs/tui2/frames/codex/frame_30.txt create mode 100644 codex-rs/tui2/frames/codex/frame_31.txt create mode 100644 codex-rs/tui2/frames/codex/frame_32.txt create mode 100644 codex-rs/tui2/frames/codex/frame_33.txt create mode 100644 codex-rs/tui2/frames/codex/frame_34.txt create mode 100644 codex-rs/tui2/frames/codex/frame_35.txt create mode 100644 codex-rs/tui2/frames/codex/frame_36.txt create mode 100644 codex-rs/tui2/frames/codex/frame_4.txt create mode 100644 codex-rs/tui2/frames/codex/frame_5.txt create mode 100644 codex-rs/tui2/frames/codex/frame_6.txt create mode 100644 codex-rs/tui2/frames/codex/frame_7.txt create mode 100644 codex-rs/tui2/frames/codex/frame_8.txt create mode 100644 codex-rs/tui2/frames/codex/frame_9.txt create mode 100644 codex-rs/tui2/frames/default/frame_1.txt create mode 100644 codex-rs/tui2/frames/default/frame_10.txt create mode 100644 codex-rs/tui2/frames/default/frame_11.txt create mode 100644 codex-rs/tui2/frames/default/frame_12.txt create mode 100644 codex-rs/tui2/frames/default/frame_13.txt create mode 100644 codex-rs/tui2/frames/default/frame_14.txt create mode 100644 codex-rs/tui2/frames/default/frame_15.txt create mode 100644 codex-rs/tui2/frames/default/frame_16.txt create mode 100644 codex-rs/tui2/frames/default/frame_17.txt create mode 100644 codex-rs/tui2/frames/default/frame_18.txt create mode 100644 codex-rs/tui2/frames/default/frame_19.txt create mode 100644 codex-rs/tui2/frames/default/frame_2.txt create mode 100644 codex-rs/tui2/frames/default/frame_20.txt create mode 100644 codex-rs/tui2/frames/default/frame_21.txt create mode 100644 codex-rs/tui2/frames/default/frame_22.txt create mode 100644 codex-rs/tui2/frames/default/frame_23.txt create mode 100644 codex-rs/tui2/frames/default/frame_24.txt create mode 100644 codex-rs/tui2/frames/default/frame_25.txt create mode 100644 codex-rs/tui2/frames/default/frame_26.txt create mode 100644 codex-rs/tui2/frames/default/frame_27.txt create mode 100644 codex-rs/tui2/frames/default/frame_28.txt create mode 100644 codex-rs/tui2/frames/default/frame_29.txt create mode 100644 codex-rs/tui2/frames/default/frame_3.txt create mode 100644 codex-rs/tui2/frames/default/frame_30.txt create mode 100644 codex-rs/tui2/frames/default/frame_31.txt create mode 100644 codex-rs/tui2/frames/default/frame_32.txt create mode 100644 codex-rs/tui2/frames/default/frame_33.txt create mode 100644 codex-rs/tui2/frames/default/frame_34.txt create mode 100644 codex-rs/tui2/frames/default/frame_35.txt create mode 100644 codex-rs/tui2/frames/default/frame_36.txt create mode 100644 codex-rs/tui2/frames/default/frame_4.txt create mode 100644 codex-rs/tui2/frames/default/frame_5.txt create mode 100644 codex-rs/tui2/frames/default/frame_6.txt create mode 100644 codex-rs/tui2/frames/default/frame_7.txt create mode 100644 codex-rs/tui2/frames/default/frame_8.txt create mode 100644 codex-rs/tui2/frames/default/frame_9.txt create mode 100644 codex-rs/tui2/frames/dots/frame_1.txt create mode 100644 codex-rs/tui2/frames/dots/frame_10.txt create mode 100644 codex-rs/tui2/frames/dots/frame_11.txt create mode 100644 codex-rs/tui2/frames/dots/frame_12.txt create mode 100644 codex-rs/tui2/frames/dots/frame_13.txt create mode 100644 codex-rs/tui2/frames/dots/frame_14.txt create mode 100644 codex-rs/tui2/frames/dots/frame_15.txt create mode 100644 codex-rs/tui2/frames/dots/frame_16.txt create mode 100644 codex-rs/tui2/frames/dots/frame_17.txt create mode 100644 codex-rs/tui2/frames/dots/frame_18.txt create mode 100644 codex-rs/tui2/frames/dots/frame_19.txt create mode 100644 codex-rs/tui2/frames/dots/frame_2.txt create mode 100644 codex-rs/tui2/frames/dots/frame_20.txt create mode 100644 codex-rs/tui2/frames/dots/frame_21.txt create mode 100644 codex-rs/tui2/frames/dots/frame_22.txt create mode 100644 codex-rs/tui2/frames/dots/frame_23.txt create mode 100644 codex-rs/tui2/frames/dots/frame_24.txt create mode 100644 codex-rs/tui2/frames/dots/frame_25.txt create mode 100644 codex-rs/tui2/frames/dots/frame_26.txt create mode 100644 codex-rs/tui2/frames/dots/frame_27.txt create mode 100644 codex-rs/tui2/frames/dots/frame_28.txt create mode 100644 codex-rs/tui2/frames/dots/frame_29.txt create mode 100644 codex-rs/tui2/frames/dots/frame_3.txt create mode 100644 codex-rs/tui2/frames/dots/frame_30.txt create mode 100644 codex-rs/tui2/frames/dots/frame_31.txt create mode 100644 codex-rs/tui2/frames/dots/frame_32.txt create mode 100644 codex-rs/tui2/frames/dots/frame_33.txt create mode 100644 codex-rs/tui2/frames/dots/frame_34.txt create mode 100644 codex-rs/tui2/frames/dots/frame_35.txt create mode 100644 codex-rs/tui2/frames/dots/frame_36.txt create mode 100644 codex-rs/tui2/frames/dots/frame_4.txt create mode 100644 codex-rs/tui2/frames/dots/frame_5.txt create mode 100644 codex-rs/tui2/frames/dots/frame_6.txt create mode 100644 codex-rs/tui2/frames/dots/frame_7.txt create mode 100644 codex-rs/tui2/frames/dots/frame_8.txt create mode 100644 codex-rs/tui2/frames/dots/frame_9.txt create mode 100644 codex-rs/tui2/frames/hash/frame_1.txt create mode 100644 codex-rs/tui2/frames/hash/frame_10.txt create mode 100644 codex-rs/tui2/frames/hash/frame_11.txt create mode 100644 codex-rs/tui2/frames/hash/frame_12.txt create mode 100644 codex-rs/tui2/frames/hash/frame_13.txt create mode 100644 codex-rs/tui2/frames/hash/frame_14.txt create mode 100644 codex-rs/tui2/frames/hash/frame_15.txt create mode 100644 codex-rs/tui2/frames/hash/frame_16.txt create mode 100644 codex-rs/tui2/frames/hash/frame_17.txt create mode 100644 codex-rs/tui2/frames/hash/frame_18.txt create mode 100644 codex-rs/tui2/frames/hash/frame_19.txt create mode 100644 codex-rs/tui2/frames/hash/frame_2.txt create mode 100644 codex-rs/tui2/frames/hash/frame_20.txt create mode 100644 codex-rs/tui2/frames/hash/frame_21.txt create mode 100644 codex-rs/tui2/frames/hash/frame_22.txt create mode 100644 codex-rs/tui2/frames/hash/frame_23.txt create mode 100644 codex-rs/tui2/frames/hash/frame_24.txt create mode 100644 codex-rs/tui2/frames/hash/frame_25.txt create mode 100644 codex-rs/tui2/frames/hash/frame_26.txt create mode 100644 codex-rs/tui2/frames/hash/frame_27.txt create mode 100644 codex-rs/tui2/frames/hash/frame_28.txt create mode 100644 codex-rs/tui2/frames/hash/frame_29.txt create mode 100644 codex-rs/tui2/frames/hash/frame_3.txt create mode 100644 codex-rs/tui2/frames/hash/frame_30.txt create mode 100644 codex-rs/tui2/frames/hash/frame_31.txt create mode 100644 codex-rs/tui2/frames/hash/frame_32.txt create mode 100644 codex-rs/tui2/frames/hash/frame_33.txt create mode 100644 codex-rs/tui2/frames/hash/frame_34.txt create mode 100644 codex-rs/tui2/frames/hash/frame_35.txt create mode 100644 codex-rs/tui2/frames/hash/frame_36.txt create mode 100644 codex-rs/tui2/frames/hash/frame_4.txt create mode 100644 codex-rs/tui2/frames/hash/frame_5.txt create mode 100644 codex-rs/tui2/frames/hash/frame_6.txt create mode 100644 codex-rs/tui2/frames/hash/frame_7.txt create mode 100644 codex-rs/tui2/frames/hash/frame_8.txt create mode 100644 codex-rs/tui2/frames/hash/frame_9.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_1.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_10.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_11.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_12.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_13.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_14.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_15.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_16.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_17.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_18.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_19.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_2.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_20.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_21.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_22.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_23.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_24.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_25.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_26.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_27.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_28.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_29.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_3.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_30.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_31.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_32.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_33.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_34.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_35.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_36.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_4.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_5.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_6.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_7.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_8.txt create mode 100644 codex-rs/tui2/frames/hbars/frame_9.txt create mode 100644 codex-rs/tui2/frames/openai/frame_1.txt create mode 100644 codex-rs/tui2/frames/openai/frame_10.txt create mode 100644 codex-rs/tui2/frames/openai/frame_11.txt create mode 100644 codex-rs/tui2/frames/openai/frame_12.txt create mode 100644 codex-rs/tui2/frames/openai/frame_13.txt create mode 100644 codex-rs/tui2/frames/openai/frame_14.txt create mode 100644 codex-rs/tui2/frames/openai/frame_15.txt create mode 100644 codex-rs/tui2/frames/openai/frame_16.txt create mode 100644 codex-rs/tui2/frames/openai/frame_17.txt create mode 100644 codex-rs/tui2/frames/openai/frame_18.txt create mode 100644 codex-rs/tui2/frames/openai/frame_19.txt create mode 100644 codex-rs/tui2/frames/openai/frame_2.txt create mode 100644 codex-rs/tui2/frames/openai/frame_20.txt create mode 100644 codex-rs/tui2/frames/openai/frame_21.txt create mode 100644 codex-rs/tui2/frames/openai/frame_22.txt create mode 100644 codex-rs/tui2/frames/openai/frame_23.txt create mode 100644 codex-rs/tui2/frames/openai/frame_24.txt create mode 100644 codex-rs/tui2/frames/openai/frame_25.txt create mode 100644 codex-rs/tui2/frames/openai/frame_26.txt create mode 100644 codex-rs/tui2/frames/openai/frame_27.txt create mode 100644 codex-rs/tui2/frames/openai/frame_28.txt create mode 100644 codex-rs/tui2/frames/openai/frame_29.txt create mode 100644 codex-rs/tui2/frames/openai/frame_3.txt create mode 100644 codex-rs/tui2/frames/openai/frame_30.txt create mode 100644 codex-rs/tui2/frames/openai/frame_31.txt create mode 100644 codex-rs/tui2/frames/openai/frame_32.txt create mode 100644 codex-rs/tui2/frames/openai/frame_33.txt create mode 100644 codex-rs/tui2/frames/openai/frame_34.txt create mode 100644 codex-rs/tui2/frames/openai/frame_35.txt create mode 100644 codex-rs/tui2/frames/openai/frame_36.txt create mode 100644 codex-rs/tui2/frames/openai/frame_4.txt create mode 100644 codex-rs/tui2/frames/openai/frame_5.txt create mode 100644 codex-rs/tui2/frames/openai/frame_6.txt create mode 100644 codex-rs/tui2/frames/openai/frame_7.txt create mode 100644 codex-rs/tui2/frames/openai/frame_8.txt create mode 100644 codex-rs/tui2/frames/openai/frame_9.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_1.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_10.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_11.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_12.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_13.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_14.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_15.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_16.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_17.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_18.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_19.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_2.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_20.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_21.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_22.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_23.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_24.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_25.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_26.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_27.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_28.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_29.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_3.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_30.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_31.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_32.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_33.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_34.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_35.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_36.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_4.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_5.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_6.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_7.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_8.txt create mode 100644 codex-rs/tui2/frames/shapes/frame_9.txt create mode 100644 codex-rs/tui2/frames/slug/frame_1.txt create mode 100644 codex-rs/tui2/frames/slug/frame_10.txt create mode 100644 codex-rs/tui2/frames/slug/frame_11.txt create mode 100644 codex-rs/tui2/frames/slug/frame_12.txt create mode 100644 codex-rs/tui2/frames/slug/frame_13.txt create mode 100644 codex-rs/tui2/frames/slug/frame_14.txt create mode 100644 codex-rs/tui2/frames/slug/frame_15.txt create mode 100644 codex-rs/tui2/frames/slug/frame_16.txt create mode 100644 codex-rs/tui2/frames/slug/frame_17.txt create mode 100644 codex-rs/tui2/frames/slug/frame_18.txt create mode 100644 codex-rs/tui2/frames/slug/frame_19.txt create mode 100644 codex-rs/tui2/frames/slug/frame_2.txt create mode 100644 codex-rs/tui2/frames/slug/frame_20.txt create mode 100644 codex-rs/tui2/frames/slug/frame_21.txt create mode 100644 codex-rs/tui2/frames/slug/frame_22.txt create mode 100644 codex-rs/tui2/frames/slug/frame_23.txt create mode 100644 codex-rs/tui2/frames/slug/frame_24.txt create mode 100644 codex-rs/tui2/frames/slug/frame_25.txt create mode 100644 codex-rs/tui2/frames/slug/frame_26.txt create mode 100644 codex-rs/tui2/frames/slug/frame_27.txt create mode 100644 codex-rs/tui2/frames/slug/frame_28.txt create mode 100644 codex-rs/tui2/frames/slug/frame_29.txt create mode 100644 codex-rs/tui2/frames/slug/frame_3.txt create mode 100644 codex-rs/tui2/frames/slug/frame_30.txt create mode 100644 codex-rs/tui2/frames/slug/frame_31.txt create mode 100644 codex-rs/tui2/frames/slug/frame_32.txt create mode 100644 codex-rs/tui2/frames/slug/frame_33.txt create mode 100644 codex-rs/tui2/frames/slug/frame_34.txt create mode 100644 codex-rs/tui2/frames/slug/frame_35.txt create mode 100644 codex-rs/tui2/frames/slug/frame_36.txt create mode 100644 codex-rs/tui2/frames/slug/frame_4.txt create mode 100644 codex-rs/tui2/frames/slug/frame_5.txt create mode 100644 codex-rs/tui2/frames/slug/frame_6.txt create mode 100644 codex-rs/tui2/frames/slug/frame_7.txt create mode 100644 codex-rs/tui2/frames/slug/frame_8.txt create mode 100644 codex-rs/tui2/frames/slug/frame_9.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_1.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_10.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_11.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_12.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_13.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_14.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_15.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_16.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_17.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_18.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_19.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_2.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_20.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_21.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_22.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_23.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_24.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_25.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_26.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_27.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_28.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_29.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_3.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_30.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_31.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_32.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_33.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_34.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_35.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_36.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_4.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_5.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_6.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_7.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_8.txt create mode 100644 codex-rs/tui2/frames/vbars/frame_9.txt create mode 100644 codex-rs/tui2/prompt_for_init_command.md create mode 100644 codex-rs/tui2/src/additional_dirs.rs create mode 100644 codex-rs/tui2/src/app.rs create mode 100644 codex-rs/tui2/src/app_backtrack.rs create mode 100644 codex-rs/tui2/src/app_event.rs create mode 100644 codex-rs/tui2/src/app_event_sender.rs create mode 100644 codex-rs/tui2/src/ascii_animation.rs create mode 100644 codex-rs/tui2/src/bin/md-events2.rs create mode 100644 codex-rs/tui2/src/bottom_pane/approval_overlay.rs create mode 100644 codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/chat_composer.rs create mode 100644 codex-rs/tui2/src/bottom_pane/chat_composer_history.rs create mode 100644 codex-rs/tui2/src/bottom_pane/command_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/feedback_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/file_search_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/footer.rs create mode 100644 codex-rs/tui2/src/bottom_pane/list_selection_view.rs create mode 100644 codex-rs/tui2/src/bottom_pane/mod.rs create mode 100644 codex-rs/tui2/src/bottom_pane/paste_burst.rs create mode 100644 codex-rs/tui2/src/bottom_pane/popup_consts.rs create mode 100644 codex-rs/tui2/src/bottom_pane/prompt_args.rs create mode 100644 codex-rs/tui2/src/bottom_pane/queued_user_messages.rs create mode 100644 codex-rs/tui2/src/bottom_pane/scroll_state.rs create mode 100644 codex-rs/tui2/src/bottom_pane/selection_popup_common.rs create mode 100644 codex-rs/tui2/src/bottom_pane/skill_popup.rs create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap create mode 100644 codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap create mode 100644 codex-rs/tui2/src/bottom_pane/textarea.rs create mode 100644 codex-rs/tui2/src/chatwidget.rs create mode 100644 codex-rs/tui2/src/chatwidget/agent.rs create mode 100644 codex-rs/tui2/src/chatwidget/interrupts.rs create mode 100644 codex-rs/tui2/src/chatwidget/session_header.rs create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap create mode 100644 codex-rs/tui2/src/chatwidget/tests.rs create mode 100644 codex-rs/tui2/src/cli.rs create mode 100644 codex-rs/tui2/src/clipboard_paste.rs create mode 100644 codex-rs/tui2/src/color.rs create mode 100644 codex-rs/tui2/src/custom_terminal.rs create mode 100644 codex-rs/tui2/src/diff_render.rs create mode 100644 codex-rs/tui2/src/exec_cell/mod.rs create mode 100644 codex-rs/tui2/src/exec_cell/model.rs create mode 100644 codex-rs/tui2/src/exec_cell/render.rs create mode 100644 codex-rs/tui2/src/exec_command.rs create mode 100644 codex-rs/tui2/src/file_search.rs create mode 100644 codex-rs/tui2/src/frames.rs create mode 100644 codex-rs/tui2/src/get_git_diff.rs create mode 100644 codex-rs/tui2/src/history_cell.rs create mode 100644 codex-rs/tui2/src/insert_history.rs create mode 100644 codex-rs/tui2/src/key_hint.rs create mode 100644 codex-rs/tui2/src/live_wrap.rs create mode 100644 codex-rs/tui2/src/markdown.rs create mode 100644 codex-rs/tui2/src/markdown_render.rs create mode 100644 codex-rs/tui2/src/markdown_render_tests.rs create mode 100644 codex-rs/tui2/src/markdown_stream.rs create mode 100644 codex-rs/tui2/src/model_migration.rs create mode 100644 codex-rs/tui2/src/onboarding/auth.rs create mode 100644 codex-rs/tui2/src/onboarding/mod.rs create mode 100644 codex-rs/tui2/src/onboarding/onboarding_screen.rs create mode 100644 codex-rs/tui2/src/onboarding/snapshots/codex_tui2__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap create mode 100644 codex-rs/tui2/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap create mode 100644 codex-rs/tui2/src/onboarding/trust_directory.rs create mode 100644 codex-rs/tui2/src/onboarding/welcome.rs create mode 100644 codex-rs/tui2/src/oss_selection.rs create mode 100644 codex-rs/tui2/src/pager_overlay.rs create mode 100644 codex-rs/tui2/src/public_widgets/composer_input.rs create mode 100644 codex-rs/tui2/src/public_widgets/mod.rs create mode 100644 codex-rs/tui2/src/render/highlight.rs create mode 100644 codex-rs/tui2/src/render/line_utils.rs create mode 100644 codex-rs/tui2/src/render/mod.rs create mode 100644 codex-rs/tui2/src/render/renderable.rs create mode 100644 codex-rs/tui2/src/resume_picker.rs create mode 100644 codex-rs/tui2/src/selection_list.rs create mode 100644 codex-rs/tui2/src/session_log.rs create mode 100644 codex-rs/tui2/src/shimmer.rs create mode 100644 codex-rs/tui2/src/skill_error_prompt.rs create mode 100644 codex-rs/tui2/src/slash_command.rs create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_add_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_delete_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_multiple_files_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_relativizes_path.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_block_wraps_long_lines_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__apply_update_with_rename_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__diff_render__tests__wrap_behavior_insert.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__active_mcp_tool_call_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesced_reads_dedupe_names.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesces_reads_across_multiple_calls.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__coalesces_sequential_reads_within_one_call.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__plan_update_without_note_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__single_line_command_compact_when_fits.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_codex.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__model_migration__tests__model_migration_prompt_gpt5_family.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__static_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__static_overlay_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_screen.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__resume_picker__tests__resume_picker_table.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_truncated.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui2__status_indicator_widget__tests__renders_with_working_header.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__add_details.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages@macos.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap create mode 100644 codex-rs/tui2/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap create mode 100644 codex-rs/tui2/src/status/account.rs create mode 100644 codex-rs/tui2/src/status/card.rs create mode 100644 codex-rs/tui2/src/status/format.rs create mode 100644 codex-rs/tui2/src/status/helpers.rs create mode 100644 codex-rs/tui2/src/status/mod.rs create mode 100644 codex-rs/tui2/src/status/rate_limits.rs create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_credits_and_limits.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_monthly_limit.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_empty_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_missing_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_shows_stale_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap create mode 100644 codex-rs/tui2/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui2/src/status/tests.rs create mode 100644 codex-rs/tui2/src/status_indicator_widget.rs create mode 100644 codex-rs/tui2/src/streaming/controller.rs create mode 100644 codex-rs/tui2/src/streaming/mod.rs create mode 100644 codex-rs/tui2/src/style.rs create mode 100644 codex-rs/tui2/src/terminal_palette.rs create mode 100644 codex-rs/tui2/src/test_backend.rs create mode 100644 codex-rs/tui2/src/text_formatting.rs create mode 100644 codex-rs/tui2/src/tooltips.rs create mode 100644 codex-rs/tui2/src/tui.rs create mode 100644 codex-rs/tui2/src/tui/frame_requester.rs create mode 100644 codex-rs/tui2/src/tui/job_control.rs create mode 100644 codex-rs/tui2/src/ui_consts.rs create mode 100644 codex-rs/tui2/src/update_action.rs create mode 100644 codex-rs/tui2/src/update_prompt.rs create mode 100644 codex-rs/tui2/src/updates.rs create mode 100644 codex-rs/tui2/src/version.rs create mode 100644 codex-rs/tui2/src/wrapping.rs create mode 100644 codex-rs/tui2/tooltips.txt diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 304343bf2c..95f4fecc48 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1596,11 +1596,68 @@ name = "codex-tui2" version = "0.0.0" dependencies = [ "anyhow", + "arboard", + "assert_matches", + "async-stream", + "base64", + "chrono", "clap", + "codex-ansi-escape", + "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-common", "codex-core", + "codex-feedback", + "codex-file-search", + "codex-login", + "codex-protocol", "codex-tui", + "codex-windows-sandbox", + "color-eyre", + "crossterm", + "derive_more 2.1.0", + "diffy", + "dirs", + "dunce", + "image", + "insta", + "itertools 0.14.0", + "lazy_static", + "libc", + "mcp-types", + "opentelemetry-appender-tracing", + "pathdiff", + "pretty_assertions", + "pulldown-cmark", + "rand 0.9.2", + "ratatui", + "ratatui-macros", + "regex-lite", + "reqwest", + "serde", + "serde_json", + "serial_test", + "shlex", + "strum 0.27.2", + "strum_macros 0.27.2", + "supports-color 3.0.2", + "tempfile", + "textwrap 0.16.2", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tree-sitter-bash", + "tree-sitter-highlight", + "unicode-segmentation", + "unicode-width 0.2.1", + "url", + "uuid", + "vt100", ] [[package]] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c3788f83f4..113c6a7515 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -663,7 +663,8 @@ async fn run_interactive_tui( codex_linux_sandbox_exe: Option, ) -> std::io::Result { if is_tui2_enabled(&interactive).await? { - tui2::run_main(interactive, codex_linux_sandbox_exe).await + let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; + Ok(result.into()) } else { codex_tui::run_main(interactive, codex_linux_sandbox_exe).await } diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index fececb1503..06308e996c 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -13,7 +13,7 @@ name = "codex-tui2" path = "src/main.rs" [features] -# Keep feature surface aligned with codex-tui while tui2 delegates to it. +# Keep feature surface aligned with codex-tui while tui2 evolves separately. vt100-tests = [] debug-logs = [] @@ -22,8 +22,95 @@ workspace = true [dependencies] anyhow = { workspace = true } +async-stream = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } +codex-ansi-escape = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } -codex-common = { workspace = true } +codex-backend-client = { workspace = true } +codex-common = { workspace = true, features = [ + "cli", + "elapsed", + "sandbox_summary", +] } codex-core = { workspace = true } +codex-feedback = { workspace = true } +codex-file-search = { workspace = true } +codex-login = { workspace = true } +codex-protocol = { workspace = true } codex-tui = { workspace = true } +color-eyre = { workspace = true } +crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +derive_more = { workspace = true, features = ["is_variant"] } +diffy = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } +itertools = { workspace = true } +lazy_static = { workspace = true } +mcp-types = { workspace = true } +opentelemetry-appender-tracing = { workspace = true } +pathdiff = { workspace = true } +pulldown-cmark = { workspace = true } +rand = { workspace = true } +ratatui = { workspace = true, features = [ + "scrolling-regions", + "unstable-backend-writer", + "unstable-rendered-line-info", + "unstable-widget-ref", +] } +ratatui-macros = { workspace = true } +regex-lite = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +shlex = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } +supports-color = { workspace = true } +tempfile = { workspace = true } +textwrap = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", + "test-util", + "time", +] } +tokio-stream = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tree-sitter-bash = { workspace = true } +tree-sitter-highlight = { workspace = true } +unicode-segmentation = { workspace = true } +unicode-width = { workspace = true } +url = { workspace = true } + +codex-windows-sandbox = { workspace = true } +tokio-util = { workspace = true, features = ["time"] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +# Clipboard support via `arboard` is not available on Android/Termux. +# Only include it for non-Android targets so the crate builds on Android. +[target.'cfg(not(target_os = "android"))'.dependencies] +arboard = { workspace = true } + + +[dev-dependencies] +codex-core = { workspace = true, features = ["test-support"] } +assert_matches = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +insta = { workspace = true } +pretty_assertions = { workspace = true } +rand = { workspace = true } +serial_test = { workspace = true } +vt100 = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/tui2/frames/blocks/frame_1.txt b/codex-rs/tui2/frames/blocks/frame_1.txt new file mode 100644 index 0000000000..8c3263f518 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_1.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒██▒▒██▒ + ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ + █░█░███ ▒░ ░ █░ ░▒░░░█ + ▓█▒▒████▒ ▓█░▓░█ + ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ + ░█ █░ ░█▓▓░░█ █▓▒░░█ + █▒ ▓█ █▒░█▓ ░▒ ░▓░ + ░░▒░░ █▓▓░▓░█ ░░ + ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ + ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ + ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ + █▒█ █ ░ ██▓█▒░ + ▒▒█░▒█▒ ▒▒▒█░▒█ + ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ + ░█ █░░░▒▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_10.txt b/codex-rs/tui2/frames/blocks/frame_10.txt new file mode 100644 index 0000000000..a6fbbf1a4b --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_10.txt @@ -0,0 +1,17 @@ + + ▒████▒██▒ + ██░███▒░▓▒██ + ▒▒█░░▓░░▓░█▒██ + ░▒▒▓▒░▓▒▓▒███▒▒█ + ▓ ▓░░ ░▒ ██▓▒▓░▓ + ░░ █░█░▓▓▒ ░▒ ░ + ▒ ░█ █░░░░█ ░▓█ + ░░▒█▓█░░▓▒░▓▒░░ + ░▒ ▒▒░▓░░█▒█▓░░ + ░ █░▒█░▒▓▒█▒▒▒░█░ + █ ░░░░░ ▒█ ▒░░ + ▒░██▒██ ▒░ █▓▓ + ░█ ░░░░██▓█▓░▓░ + ▓░██▓░█▓▒ ▓▓█ + ██ ▒█▒▒█▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_11.txt b/codex-rs/tui2/frames/blocks/frame_11.txt new file mode 100644 index 0000000000..88e3dfa7c5 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_11.txt @@ -0,0 +1,17 @@ + + ███████▒ + ▓ ▓░░░▒▒█ + ▓ ▒▒░░▓▒█▓▒█ + ░▒▒░░▒▓█▒▒▓▓ + ▒ ▓▓▒░█▒█▓▒░░█ + ░█░░░█▒▓▓░▒▓░░ + ██ █░░░░░░▒░▒▒ + ░ ░░▓░░▒▓ ░ ░ + ▓ █░▓░░█▓█░▒░ + ██ ▒░▓▒█ ▓░▒░▒ + █░▓ ░░░░▒▓░▒▒░ + ▒▒▓▓░▒█▓██▓░░ + ▒ █░▒▒▒▒░▓ + ▒█ █░░█▒▓█░ + ▒▒ ███▒█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_12.txt b/codex-rs/tui2/frames/blocks/frame_12.txt new file mode 100644 index 0000000000..c6c0ef3e87 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_12.txt @@ -0,0 +1,17 @@ + + █████▓ + █▒░▒▓░█▒ + ░▓▒██ + ▓█░░░▒▒ ░ + ░ █░░░░▓▓░ + ░█▓▓█▒ ▒░ + ░ ░▓▒░░▒ + ░ ▓█▒░░ + ██ ░▓░░█░░ + ░ ▓░█▓█▒ + ░▓ ░ ▒██▓ + █ █░ ▒█░ + ▓ ██░██▒░ + █▒▓ █░▒░░ + ▒ █░▒▓▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_13.txt b/codex-rs/tui2/frames/blocks/frame_13.txt new file mode 100644 index 0000000000..7a090e51e3 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_13.txt @@ -0,0 +1,17 @@ + + ▓████ + ░▒▒░░ + ░░▒░ + ░██░▒ + █ ░░ + ▓▓░░ + █ ░░ + █ ░ + ▓█ ▒░▓ + ░ █▒░ + █░▓▓ ░░ + ░▒▒▒░ + ░██░▒ + █▒▒░▒ + █ ▓ ▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_14.txt b/codex-rs/tui2/frames/blocks/frame_14.txt new file mode 100644 index 0000000000..f5e74d12b7 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_14.txt @@ -0,0 +1,17 @@ + + ████▓ + █▓▒▒▓▒ + ░▒░░▓ ░ + ░░▓░ ▒░█ + ░░░▒ ░ + ░█░░ █░ + ░░░░ ▓ █ + ░░▒░░ ▒ + ░░░░ + ▒▓▓ ▓▓ + ▒░ █▓█░ + ░█░░▒▒▒░ + ▓ ░▒▒▒░ + ░▒▓█▒▒▓ + ▒█ █▒▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_15.txt b/codex-rs/tui2/frames/blocks/frame_15.txt new file mode 100644 index 0000000000..f04599ea27 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_15.txt @@ -0,0 +1,17 @@ + + █████░▒ + ░█▒░░▒▓██ + ▓▓░█▒▒░ █░ + ░▓░ ▓▓█▓▒▒░ + ░░▒ ▒▒░░▓ ▒░ + ▒░░▓░░▓▓░ + ░░ ░░░░░░█░ + ░░▓░░█░░░ █▓░ + ░░████░░░▒▓▓░ + ░▒░▓▓░▒░█▓ ▓░ + ░▓░░░░▒░ ░ ▓ + ░██▓▒░░▒▓ ▒ + █░▒█ ▓▓▓░ ▓░ + ░▒░░▒▒▓█▒▓ + ▒▒█▒▒▒▒▓ + ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_16.txt b/codex-rs/tui2/frames/blocks/frame_16.txt new file mode 100644 index 0000000000..1eb080286e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_16.txt @@ -0,0 +1,17 @@ + + ▒▒█ ███░▒ + ▓▒░░█░░▒░▒▒ + ░▓▓ ▒▓▒▒░░ █▒ + ▓▓▓ ▓█▒▒░▒░░██░ + ░░▓▒▓██▒░░█▓░░▒ + ░░░█░█ ░▒▒ ░ ░▓░ + ▒▒░ ▓░█░░░░▓█ █ ░ + ░▓▓ ░░░░▓░░░ ▓ ░░ + ▒▒░░░█░▓▒░░ ██ ▓ + █ ▒▒█▒▒▒█░▓▒░ █▒░ + ░░░█ ▓█▒░▓ ▓▓░░░ + ░░█ ░░ ░▓▓█ ▓ + ▒░█ ░ ▓█▓▒█░ + ▒░░ ▒█░▓▓█▒░ + █▓▓▒▒▓▒▒▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_17.txt b/codex-rs/tui2/frames/blocks/frame_17.txt new file mode 100644 index 0000000000..dd5f5c8da5 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_17.txt @@ -0,0 +1,17 @@ + + █▒███▓▓░█▒ + ▒▓██░░░█▒█░█ ▒█ + ██▒▓▒▒▒░██ ░░░▒ ▒ + ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ + ░▓▒ ░ ░ ▓▒▒▒▓▓ █ + ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ + █ ▓▓░ █▓▓░▒ █ ░░▓▒░ + ▓ ▒░ ▓▓░░▓░█░░▒▓█ + █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ + ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ + ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ + ▒░░▒█ ░▓░░░ ▓ + ▒▒▒ █▒▒ ███▓▒▒▓ + █ ░██▒▒█░▒▓█▓░█ + ░█▓▓▒██░█▒██ + ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_18.txt b/codex-rs/tui2/frames/blocks/frame_18.txt new file mode 100644 index 0000000000..a6c93e6c01 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_18.txt @@ -0,0 +1,17 @@ + + ▒▒▒█▒▒█▓░█▒ + ▒█ ▒▓███░▒▒█ █▓▓▒ + ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ + █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ + ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ + ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ + ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ + ▒▓░░ ▒▒ ░░▓▒ ░▓░ + ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ + ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ + ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ + ▒▒ █▒█ ░░█ ▓█▒█ + ▒▒██▒▒▓ ▒█▒▒▓▒█░ + █░▓████▒▒▒▒██▒▓▒██ + ░░▒▓▒▒█▓█ ▓█ + ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_19.txt b/codex-rs/tui2/frames/blocks/frame_19.txt new file mode 100644 index 0000000000..73341b5d58 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_19.txt @@ -0,0 +1,17 @@ + + ▒▒▒▒█░█▒▒░▓▒ + ▒█░░░▒▓▒▒▒▒█▒█░███ + ██▓▓▓ ░██░ ░█▓█░█▓▒ + ▓▓░██▒░ ▒▒▒██▒░██ + ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ + ░▒▓██ ▒░█░▓ ▓▓ █▓█░ + ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ + ░░░░ ░▓ ▒░ █ ░ ░░░ + ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ + ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ + █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ + ▒▒▒█▒▒ ░▓▓▒▓▓█ + █ ▒▒░░██ █▓▒▓▓░▓░ + █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ + ░▓▒▓▓█▒█▓▒█▓▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_2.txt b/codex-rs/tui2/frames/blocks/frame_2.txt new file mode 100644 index 0000000000..1c7578c970 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_2.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒█▒▒▒██▒ + ▒██▓█▓█░░░▒░░▒▒█░██▒ + █░█░▒██░█░░ ░ █▒█▓░░▓░█ + ▒░▓▒▓████▒ ▓█▒░▓░█ + █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ + █▓█ ░ ░█▒█▓▒█ ▒▒░█░ + █░██░ ▒▓░▓░▒░█ ▓ ░ ░ + ░ ▒░ █░█░░▓█ ░█▓▓░ + █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ + ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ + ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ + █▒▒▓▓█ ░ ██▓▓██ + ▓▒▒▒░██ █▒▒█ ▒░ + ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ + ░████░░▒▒▒▒░▓▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_20.txt b/codex-rs/tui2/frames/blocks/frame_20.txt new file mode 100644 index 0000000000..3e0c5f0d9c --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_20.txt @@ -0,0 +1,17 @@ + + ▒▒█▒░░▒█▒█▒▒ + █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ + ██ ▒██ ░█ ░ ▒ ▒██░█▒ + ▒░ ▒█░█ ▒██░▒▓█▒▒ + ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ + ▓░█░█ ███▓░ ▓ █▒░░▒ + ▓░▓█░ ██ ▓██▒ █▒░▓ + ░▒▒▓░ ▓▓░ █ ░░ ░ + ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ + █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ + ▒█▒▒█ █ ▒█▒░░█░ ▓▒ + █ ▒█▓█ ▒▓█▓░▓ + ▒▒▒██░▒ █▓█░▓██ + ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ + ░██░▒ ▒▒▒▒▒░█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_21.txt b/codex-rs/tui2/frames/blocks/frame_21.txt new file mode 100644 index 0000000000..971877651f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_21.txt @@ -0,0 +1,17 @@ + + ▒▒█▒█▒▒█▒██▒▒ + ███░░▒▒█░▒░█▓▒░▓██▒ + ▓█▒▒██▒ ░ ░▒░██▒░██ + ██░▓ █ ▒█▓██▓██ + ▓█▓█░ █░▓▒▒ ▒▒▒▒█ + ▓ ▓░ ███▒▓▓ ▒▒▒█ + ░█░░ ▒ ▓░█▓█ ▒▓▒ + ░▒ ▒▓ ░█ ░ ░ + ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ + ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ + ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ + ██ ▓▓▒ ▒█ ▓ + █▒ ▒▓▒ ▒▓▓██ █░ + █▒▒ █ ██▓░░▓▓▒█ ▓░ + ███▓█▒▒▒▒█▒▓██░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_22.txt b/codex-rs/tui2/frames/blocks/frame_22.txt new file mode 100644 index 0000000000..2713fd669e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_22.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒▒█▒██▒ + ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ + █▓█▓░▒██░ ░ █▒███▒▒██ + ▓█░██ ██░░░▒█▒ + ▒░░▓█ █▒▓░▒░▓▓▓█░ + ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ + ░██▒▓ ░█░▒█▓█ ░░▓░ + ░░▒░░ ░▒░░▒▒ ░▒░ ░ + ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ + ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ + ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ + ▒█▒▒▒█ ▒██ ░██ + █ █▓ ██▒ ▒▓██ █▒▓ + █▓███ █░▓▒█▓▓▓▒█ ███ + ░ ░▒▓▒▒▒▓▒▒▓▒█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_23.txt b/codex-rs/tui2/frames/blocks/frame_23.txt new file mode 100644 index 0000000000..39a6c55644 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_23.txt @@ -0,0 +1,17 @@ + + ▒██▒▒████▒█▒▒ + ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ + █ █░██▓█░ ░▓█░▒▓░░█ + ▓▓░█▓▓░ ▒▓▓▒░░▓▒ + ▓▓░░▓█ █▓████▓█▒░▒ + █▒░ ▓░ ▒█████▓██░░▒░█ + ░░░ ░ ▓▓▓▓ ▒░░ ░██ + ░▓░ ░ ░ ░█▒▒█ ░ █▓░ + ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ + ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ + ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ + █░░▒░▓ █▓░▒▒▓░ + ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ + ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ + ░ █▒▒░▒▒█▒▒██░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_24.txt b/codex-rs/tui2/frames/blocks/frame_24.txt new file mode 100644 index 0000000000..90ccc262f0 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_24.txt @@ -0,0 +1,17 @@ + + ▒░▒▓███▒▒█▒ + █ ▒▓ ░▒▒░▒▒██▒██ + █ █▓▒▓█ ░ ▓░▓█░███ ▒ + ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ + █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ + ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ + ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ + ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ + ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ + ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ + ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ + ▒ ▒██▒ ░ ▓▓▓ + ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ + ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ + █▓▒██▓▒▒▒▒▒██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_25.txt b/codex-rs/tui2/frames/blocks/frame_25.txt new file mode 100644 index 0000000000..d8fd5b45a8 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_25.txt @@ -0,0 +1,17 @@ + + ▒█▒█▓████▒ + █ ███░▒▓▒░█░░█ + ▓░▓▓██ ▓░█▒▒▒░░░▒ + ░██░ ▓ ▒░ ▒░██▒▓ + █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ + ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ + ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ + ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ + ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ + ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ + ▒░█▓▒░░ ██▓░▒░▓░░ + ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ + ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ + ▒▓▓▒▒▒█▒░██ █░█ + ░█ █▒██▒█░█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_26.txt b/codex-rs/tui2/frames/blocks/frame_26.txt new file mode 100644 index 0000000000..a4734b4486 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_26.txt @@ -0,0 +1,17 @@ + + ▒▓███ ██ + ▓█░▓▓▒░█▓░█ + ▓█ ░▓▒░▒ ▒█ + ▓█ █░░░▒░░▒█▓▒ + ░▒█▒░▓░ █▒▓▓░▒▓ + ▒ ░▓▓▓ █▒▒ ▒▒▓ + ░ ██▒░░▓░░▓▓ █ + ▓▓ ▒░░░▒▒▒░░▓░░ + ░ ▓▒█▓█░█▒▒▓▒░░ + ▓▒░▓█░▒▒██▒▒█░ + ░░ ▓░█ ▒█▓░█▒░░ + ▒▒░░▓▒ ▓▓ ░░░ + █ █░▒ ▒░▓░▓█ + ░ █▒▒ █▒██▓ + ▒▓▓▒█░▒▒█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_27.txt b/codex-rs/tui2/frames/blocks/frame_27.txt new file mode 100644 index 0000000000..b99e90e6d4 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_27.txt @@ -0,0 +1,17 @@ + + ▓█████ + ░▓▓▓░▓▒ + ▓█░ █░▓█░ + ░░░▒░░▓░░ + ░ ░░▒▓█▒ + ░▒▓▒ ░░░░░ + ▒ ░░▒█░░ + ░ ░░░░▒ ░░ + ░▓ ▓ ░█░░░░ + █▒ ▓ ▒░▒█░░ + ░▓ ▒▒███▓█ + ░░██░░▒▓░ + ░▒▒█▒█▓░▒ + ▒▒▒░▒▒▓▓ + █▒ ▒▒▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_28.txt b/codex-rs/tui2/frames/blocks/frame_28.txt new file mode 100644 index 0000000000..de6db173b4 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_28.txt @@ -0,0 +1,17 @@ + + ▓██▓ + ░█▒░░ + ▒ ▓░░ + ░▓░█░ + ░ ░░ + ░ ▓ ░ + ▒░░ ▒░ + ░▓ ░ + ▓▒ ▒░ + ░░▓▓░░ + ░ ▒░ + ░▒█▒░ + ░▒█░░ + █▒▒▓░ + ░ ▓█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_29.txt b/codex-rs/tui2/frames/blocks/frame_29.txt new file mode 100644 index 0000000000..d7b871c9c3 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_29.txt @@ -0,0 +1,17 @@ + + ██████ + █░█▓ █▒ + ▒█░░ █ ░ + ░░░░▒▒█▓ + ▒ ░ ░ ░ + ░█░░░ ▒▒ + ░▒▒░░░ ▒ + ░░▒░░ + ░░░█░ ░ + ▒░▒░░ ░ + █░░▓░▒ ▒ + ░▓░░░ ▒░ + ░░░░░░▒░ + ░▒░█▓ ░█ + ░░█ ▓█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_3.txt b/codex-rs/tui2/frames/blocks/frame_3.txt new file mode 100644 index 0000000000..833b2b3db2 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_3.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓██▒▒▒▒█▒ + ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ + █▓▓█▓░█ ░ ░ ░ ███▓▒░█ + ▓█▓▒░▓██▒ ░▒█ ░░▒ + █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ + ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ + ▒█ ░ ██▒░▒ ░█ ▓█▓░█ + █▓░█░ █▓░ ▓▒░ ░▒░▒░ + ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ + █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ + █▒░ ░ ░░██ ███ ███▓▓▓█▓ + ██░ ▒█ ░ ▓▒█▒▓▓ + ▒▒▓▓█▒█ ██▓▓ █░█ + ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ + ░███▒▓░▒▒▒▒░▓▓▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_30.txt b/codex-rs/tui2/frames/blocks/frame_30.txt new file mode 100644 index 0000000000..9c27cf67d0 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_30.txt @@ -0,0 +1,17 @@ + + ▒▓ ████ + ▒▓▓░░▒██▒▒ + █▒░█▒▒░██▒ + ░░▒░▓░▒▒░▒ ▒█ + ▒█░░░▒░█░█ ░ + ░█░▒█ █░░░░▓░ + ▒▓░░░▒▒ ▒▓▒░ ▒░ + ░ ██▒░█░ ░▓ ░ + ░▒ ▒░▒░▒▓░█ ░ + ░░▒░▒▒░░ ██ ░ + ▒░░▓▒▒█░░░█░░ + ░█▓▓█▓█▒░░ ░ + ▒░▒░░▓█░░█░▓ + █▒██▒▒▓░█▓█ + ▒▓▓░▒▒▒▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_31.txt b/codex-rs/tui2/frames/blocks/frame_31.txt new file mode 100644 index 0000000000..c787451d71 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_31.txt @@ -0,0 +1,17 @@ + + ▒▓▓████▒█ + ▒██▓██▒ █▒███ + █░▒▓▓█▒▒░▓ ░▒█▒ + █░▓█▒▒█▓▒█▒▒░▒░░▒ + ▒░░░░█▓█▒▒█ ▒░▓▒▒ + ▓░▒░░▒░█ ▒▓██▓▓░█ ░ + ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ + ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ + ░▒█▒░█▒░░░▓█░░░▒ ░ + ░░░▓▓░░▒▒▒▒▒░▒░░ █ + ▒█▒▓█░█ ▓███░▓░█░▒ + ░░░▒▒▒█ ▒▒█ ░ + ▓░█▒▒ █ ▓ ░█░▓░ + ▓░▒░▓▒░░█░ █░░ + █ ▒░▒██▓▓▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_32.txt b/codex-rs/tui2/frames/blocks/frame_32.txt new file mode 100644 index 0000000000..e5e7adf64d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_32.txt @@ -0,0 +1,17 @@ + + █████▓▓░█▒ + ▓█░██░▒░░██░░█ + ▓▒█▒▒██▒▓▓░█░█▒███ + █▓▓░▒█░▓▓ ▓ █▒▒░██ █ + ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ + ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ + █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ + ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ + ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ + █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ + █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ + ▒█░▒▒▒ █▒░▓█░ + ███░░░█▒ ▒▓▒░▓ █ + ▒▓▒ ░█░▓▒█░▒█ ▒▓ + ░▓▒▒▒██▓█▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_33.txt b/codex-rs/tui2/frames/blocks/frame_33.txt new file mode 100644 index 0000000000..31a607b29c --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_33.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒█▒░▓▒ + ▒██░░▒█▒░▓░▓░█░█▓ + ▒▓▒░████▒ ░ █▓░░█ █ + █▒▓░▓▒░█▒ █░░▒▒█ + ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ + ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ + ░░█ ▒██░▒░▒ ░█░░ + █░██ ███▒▓▒█ ▒ ░█ + ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ + ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ + ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ + ▒█▒██ █ ▓▓▓▓▒▓ + █▒ ███▓█ ▒█░█▓█▒█ + ▒░ █▒█░█▓█▒ ▓█▒█░█ + ▒▒██▒▒▒▒██▓▓ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_34.txt b/codex-rs/tui2/frames/blocks/frame_34.txt new file mode 100644 index 0000000000..db99cb73d6 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_34.txt @@ -0,0 +1,17 @@ + + ▒█▒████▒░█▒ + ▒███▓▒▓░ ░██▒██▓█▒▒ + ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ + █▓▒░█▒▓█▒ █░██▒▒ + ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ + █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ + ░█▒░▒ █▒▒█░▒▒ ░▓░▒ + ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ + ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ + ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ + ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ + ▓█▒▓▒▒▒ ▓▓▓░▓█ + ▒█░░█▒▓█ ▒█▒ ▒▓█░ + ▓▒▓░ ░██▓██▒█▒█░██▓█ + ░▒▓▒▒▒▒▒▒▓▒█▒▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_35.txt b/codex-rs/tui2/frames/blocks/frame_35.txt new file mode 100644 index 0000000000..814188563d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_35.txt @@ -0,0 +1,17 @@ + + ▒██▓▒███▒██▒ + ██▒█▓░███ ░█░▓ ░█▒▒ + ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ + █▓▓█▓█▓█▒ ██▒▒░▒ + ▓▓░░▓▓▒ ▒██ ░▒█░█ + ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ + ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ + ░░░▓ ▓▒▒▓▓█ ▓ ▓ + ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ + ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ + ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ + ▓▒▒▓░ ▒▓█ █▒ + ▒▓░▒▓█▓█ █▓▓▒███ + ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ + ▒░▓▒▒▒▓▒▒███ ▒ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_36.txt b/codex-rs/tui2/frames/blocks/frame_36.txt new file mode 100644 index 0000000000..cde83b56f4 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_36.txt @@ -0,0 +1,17 @@ + + ▒▒█▒████▒██▒ + ▒▒ ▒█▓▓▓█▒█▓██ ███▒ + █▒█▒███▓█ ░░ ░ █░██░██░█ + ▒░ ██▒▒▒▒ ██░▒ ░ + █▓▒▓▒█░▒░▒█▓ ▒▒▓█ + ▓ █▓░ █▒ ░▓█ ▒▒█ + ░ ▓ ░ ▒ ▒▒ ░▒░█ + ░░▒░ ▒▒ ▒▓▓ ▒░ ░ + ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ + ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ + █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ + ██ ▒▓▓ █▓░ ▓ + ▒▓░░█░█ ███ ▓█░ + ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ + ░██▓░▒██▒██████ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_4.txt b/codex-rs/tui2/frames/blocks/frame_4.txt new file mode 100644 index 0000000000..7ad27d16e7 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_4.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓█▒▒█▒██▒ + ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ + ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ + ▓▓░█░ ▓██ ░██▒█▒ + ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ + ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ + ▒░▓░ ███▒█ ░█ █ ▓░ + ░▓▒ █░▓█▒░░ ░░░ + ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ + ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ + ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ + █░▒ ░▒░ █▒▓▓███ + ▒▒█▓▒ █▒ ▒▓▒██▓░▓ + ░░░▒▒██▒▓▓▒▓██▒██▒░█░ + █▒▒░▓░▒▒▒▒▒▓▓█░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_5.txt b/codex-rs/tui2/frames/blocks/frame_5.txt new file mode 100644 index 0000000000..24f9843954 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_5.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒▒██▒ + ▒█ █▓█▓░░█░▒█▓▒░ ██ + █▒▓▒█░█ ░ ▒▒░█▒ ███ + █░▓░▓░▓▒█ ▓▒░░░░▒ + █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ + ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ + ░▓░ █ ░▒▒░▒ ░█ ██░█░█ + ░▓░▒ █▒▒░▓▒░ █░▒░ + ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ + ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ + ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ + ▒▒█▓▒▒ ░ ▓▒▓██▒ + ▒▓█▒░▒█▒ ▒▒████▓█ + ▒░█░███▒▓░▒▒██▒█▒░▓█ + ▒▓█▒█ ▒▒▒▓▒███░ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_6.txt b/codex-rs/tui2/frames/blocks/frame_6.txt new file mode 100644 index 0000000000..fe185a7573 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_6.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒██▒▒ + █▒▓▓█░▒██░██▓▒███▒ + ███░░░█ ░ ░▓▒███▓▒▒ + ▓█░█░█▒▒█ ▒█░░░░█ + █▒░░░█▒▒██▒ ▓▒▒░▒█ + ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ + ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ + ░ ░░░ ▒░▒░▓░░ ░█▒░░ + ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ + ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ + ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ + █ ▓ ▓█░ █▓▒▓▓░░ + ▓░▒▒███ ▒█▒▒▓███ + ░ ░██ █ ▓░▒▒████ ▓▓█ + ▒▓▓███▒▒▒░▒███ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_7.txt b/codex-rs/tui2/frames/blocks/frame_7.txt new file mode 100644 index 0000000000..7441f97e96 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_7.txt @@ -0,0 +1,17 @@ + + ▒▓░▓██▒▒██▒ + ██░█▒░███▒▒▒▓ ░██ + █ █░░░█░ ░▒░░ █▓▒██ + ▒▒░░░░▓█ ▒░▒█░▓█ + ░█░█░░▒░▓▒█ ▓ █░░▒ + ░ ▓░░ ░█▒▓░▒ █▓░░░ + ░▒ ░ ▒▒░▒░▒░ ██▒░░ + ▒ ▓░░ ▒█▓░█░░ █ ░░░ + ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ + ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ + ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ + █▒ ▓▒▓▒░ █ ▓▓▓▓█ + █▓██▒▒▒▒ █▒░██▓██ + ▒▒█▒░█▒▓░▒▒▒██░██▓ + ░█ ░▓░▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_8.txt b/codex-rs/tui2/frames/blocks/frame_8.txt new file mode 100644 index 0000000000..ea88b09538 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_8.txt @@ -0,0 +1,17 @@ + + ▒▒█▒▓██▒██▒ + █ █▓░░░█▒▒ ░ █ + ▒░▒█░▓▓█ █ ░▓░█▒█▒█ + ▒█▒█▓░██░ █ ▒▒░░▒ + █ ▓░▓█▒░▓▒ ▓█▒░░█ + ░██░▒▒▒▒▒░▒█ ▒█░░░ + ░█░░░ █▒▓▒░░░ ░▒░▓░█ + ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ + ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ + ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ + ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ + ██ ░▒░░░ ▓█▓▓▓█ + █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ + ▓▓█░██ ▓▓██▓▓▒█░░ + ░░▒██▒░▒██▓▒░ + ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_9.txt b/codex-rs/tui2/frames/blocks/frame_9.txt new file mode 100644 index 0000000000..9066ba1bed --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_9.txt @@ -0,0 +1,17 @@ + + ▓▒▒█▓██▒█ + ▓█▒▓░░█ ▒ ▒▓▒▒ + ▓ █░░▓█▒▒▒▓ ▒▒░█ + ░░▓▓▒▒ ▒▒█░▒▒░██ + ▓█ ▓▒█ ░██ █▓██▓█░░ + ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ + ░ ░█▒░██░▒▒█ ▓█▓ ░░░ + ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ + █░ ▓░ ░░░░█░░█░░░ + ░▒░░░▒█░▒░▒░░░░▒▒░░░ + ░▒▓▒▒░▓ ████░░ ▓▒░ + ▒░░░▒█░ █▓ ▒▓░░ + ▒█▒░▒▒ ▓▓▒▓░▓█ + ▒▓ ▒▒░█▓█▒▓▓█░░ + █▓▒ █▒▒░▓█▓ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_1.txt b/codex-rs/tui2/frames/codex/frame_1.txt new file mode 100644 index 0000000000..63249f4242 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_1.txt @@ -0,0 +1,17 @@ + + eoeddccddcoe + edoocecocedxxde ecce + oxcxccccee eccecxdxxxc + dceeccooe ocxdxo + eedocexeeee coxeeo + xc ce xcodxxo coexxo + cecoc cexcocxe xox + xxexe oooxdxc cex + xdxce dxxeexcoxcccccceco dc x + exdc edce oc xcxeeeodoooxoooox + eeece eeoooe eecccc eccoodeo + ceo co e ococex + eeoeece edecxecc + ecoee ccdddddodcceoxc + ecccxxxeeeoedccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_10.txt b/codex-rs/tui2/frames/codex/frame_10.txt new file mode 100644 index 0000000000..fe5e51b984 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_10.txt @@ -0,0 +1,17 @@ + + eccccecce + ccecccexoeco + eeoxxoxxoxceoo + xeeoexdeoeocceeo + o dxxcxe cooeoxo + xe cxcxooe eecx + e xcccxxxxc xoo + c xxecocxxoeeoexx + c xe eexdxxcecdxx + x oxeoxeoeceeexce + o cxxxxxcc eocexe + eecoeocc exccooo + xc xxxxcodooxoe + deccoxcde ooc + co eceeodc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_11.txt b/codex-rs/tui2/frames/codex/frame_11.txt new file mode 100644 index 0000000000..48e507a84a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_11.txt @@ -0,0 +1,17 @@ + + occcccce + oc dxxxeeo + oceexxdecoeo + xeexxddoedoo + ecodexcecdexxo + xcexxceddxeoxx + cc oxxxxxxexde + x xxoxxeo xcx + o cxoxxcocxex + cc exodocoxexe + ceo xxxxdoxeex + eeooxecoccdxe + e cxeeeexdc + ec cxxoeoce + ee cccece + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_12.txt b/codex-rs/tui2/frames/codex/frame_12.txt new file mode 100644 index 0000000000..29de69516a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_12.txt @@ -0,0 +1,17 @@ + + ccccco + odeeoxoe + c xoeco + ocxxxddcx + x cxxxxoox + xcoocecexc + x xoexxe + x ocexxc + co xoxxcxx + x oxcdce + xo xcdcco + o cx eox + o ccxocex + ceocoxexe + e cxeoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_13.txt b/codex-rs/tui2/frames/codex/frame_13.txt new file mode 100644 index 0000000000..67fe336a13 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_13.txt @@ -0,0 +1,17 @@ + + occco + xeexx + xeexc + xccxe + c xx + cdoxx + o xx + c cx + oc exo + xc cdx + ceoo xe + xeeex + xcoxe + ceexd + o ocd + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_14.txt b/codex-rs/tui2/frames/codex/frame_14.txt new file mode 100644 index 0000000000..f8d32cd6d1 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_14.txt @@ -0,0 +1,17 @@ + + ccccd + ooeeoe + xexxo x + xxoxcexo + xxxe x + xcxx cx + xxxx o c + xxexe e + xxxx c + ceoo do + exccooox + xcxxeeex + o cxddde + xeoceeo + ec cdo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_15.txt b/codex-rs/tui2/frames/codex/frame_15.txt new file mode 100644 index 0000000000..2e14341237 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_15.txt @@ -0,0 +1,17 @@ + + cccccxe + eodxxedco + ooxcdexccx + xoe ooooeex + xxdcdexxocex + exxoxxoox c + xx xxxxxxox + xxoxxcxxx cox + xxcoocxxxeodx + xexdoxexco ox + xoxxxxex e d + xccoexxeo d + cxeo oooe de + xexxeeoceo + eeceeeeo + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_16.txt b/codex-rs/tui2/frames/codex/frame_16.txt new file mode 100644 index 0000000000..c90ce92cb6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_16.txt @@ -0,0 +1,17 @@ + + edcccccxe + oexxcxxexde + xooceodexx ce + ooo dceexexxccx + xxdeoccdxxcoxee + xxxcxc xed x xox + eex oeoxxxxocco x + xod xexxoxxxcd ex + eexxxcxoexxccc o + cceeoddecxoex oex + xxxcccocexdcdoxxe + xxc xe eooo o + exc x oooeox + exxcecxoocex + cdoeddeedc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_17.txt b/codex-rs/tui2/frames/codex/frame_17.txt new file mode 100644 index 0000000000..e1f2bb6d96 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_17.txt @@ -0,0 +1,17 @@ + + odcccddxoe + edccxxxcdcxoceo + oceoeddecocxxxece + oxoeoxcee cxdexxxde + xoe x xcoedeoo o + edcooe odox oodoxoo + c dox oooxe ccxxodx + ocdx ooxxoxoxxddc + oocoeddcxeexeedexxx x + xcedeexoceoxxe eccce + eeeoccccccceexcooe ec + exxec eoxxe d + eee cee ocooeeo + o xccdeceedcdxc + ecdoeocxcecc + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_18.txt b/codex-rs/tui2/frames/codex/frame_18.txt new file mode 100644 index 0000000000..be64251770 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_18.txt @@ -0,0 +1,17 @@ + + eddcddcdxoe + eccedoccxeeoccdde + eodxcccdcocoeccooe c + oxxcooecc ceeeodxedeeeo + eeoo ox ecceeoxoxeedeee + oex ooxoeeeoocoxcooeoeox + xxedo cocoxceoccxdxdo + ceoxx eecxxde xdxc + ecc oedddddcxxoxcoeo xcxe + eeexcec xxoeeeexxxedxee o + xoxeeccccccce eeeoxocoeoe + ee oeo eeccocec + eecceeo eceeoeoe + cxoccccdddecceoeoc + cxxeoeeooccdcc + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_19.txt b/codex-rs/tui2/frames/codex/frame_19.txt new file mode 100644 index 0000000000..8904157121 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_19.txt @@ -0,0 +1,17 @@ + + eeddcxcddxoe + ecxxxeodddeceoxcoo + ocddocxcce ecdoecde + odxcoee eddcoexco + xxoeoe oxecocxe xeo + xeocc excxo oo cocx + edxxc oceoxcoe odocx + xxxx xdcexco x xxx + xcxeoddddddxxxxccdcxd e cxx + edooxdcoecceoeo ee deeeoooxe + cecocxcccccccc eeeoxoo ooc + eeecee eooeooc + c eexxco oddooxde + ccoxcoxceeddocc dcxc + cxoedoceooecoe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_2.txt b/codex-rs/tui2/frames/codex/frame_2.txt new file mode 100644 index 0000000000..a3c0663db4 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_2.txt @@ -0,0 +1,17 @@ + + eoeddcdddcoe + ecoocdcxxxdxxdecxcce + oxcxeccxcee eccdcoxxdxo + exoeoccooe ooexoxo + oecocexeeeee eoxexo + cocce xcecoec eexcx + oxccx eoxdxexo ocxcx + xc ee oxcxxdc xcoox + cccdx dxeeexcoxccccccccoxexxc + edcx oxxc oc xdeeeeeooeexco x + eee c ceooxc ecccccccccxocxx + ceeooo e ocdooc + oeeexco odec exc + exedeecccdddddodceexxc + eccccxxeeeexdocc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_20.txt b/codex-rs/tui2/frames/codex/frame_20.txt new file mode 100644 index 0000000000..cea5393f75 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_20.txt @@ -0,0 +1,17 @@ + + eecdxxdcdoee + oddcdoeodddxxeececo + oocecccxcc ecececcxce + excecxc eocxeocee + ex oxc eo exxecexxe + oeoxc cccdxco cexxe + dxdcx oc occe oexo + xeeoe ccddxco xxcx + xoxxdoddddddddeocdeeeec o xe + cxexec oeeeeeexe ceecxde oo xx + eoeecccccccccc eodxxox oe + c ecoo eocoxo + eeecoxe odcedcc + eooocxceddodcxceoocc + eccxe deeeexccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_21.txt b/codex-rs/tui2/frames/codex/frame_21.txt new file mode 100644 index 0000000000..efa6d610d9 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_21.txt @@ -0,0 +1,17 @@ + + eeodcddcdcoee + occeeeecxdxcdeeocce + dceeccece eexcceeco + ocxdcc eodcodco + oooce oxoee eeeeo + ocox occeoo eeeo + xcxe e oeooc edec + ee ed cxo x x + x x ocdddddccc exocxo do x + x xe xe eox ececxo ocoo + d co eeccc ce cceod oe o + cc dde ecc o + ce eoe eodcc oe + cde ccccdxxdddccc oe + cccdceeeeoedcce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_22.txt b/codex-rs/tui2/frames/codex/frame_22.txt new file mode 100644 index 0000000000..91c9c2ecaa --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_22.txt @@ -0,0 +1,17 @@ + + eocdcddcdcoe + ecocxoeoxoexxdxcocce + odcdxecce ecceccceeco + dcxccc ooxxxece + exxoc oeoxdxoodcx + excoe oxoxdeoe exedx + xcceo xcxecoc xxox + xxdxe xexxee xexcx + xxoco cxddddddcceecxe eo exdc + exd ceeeo oocxoox ecdecxoo oed + eeeex cccccccce edcceooocoe + eceeeo ecocxoc + cccd cce eococceo + cdccoccxddcddodccccoc + cxcxedeeeodeodce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_23.txt b/codex-rs/tui2/frames/codex/frame_23.txt new file mode 100644 index 0000000000..5b5f1be139 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_23.txt @@ -0,0 +1,17 @@ + + eocedccccdcee + edxcxeeoeddoxexcxce + occxcodce cxdcxedxxo + odxcdoe eddexxde + ooxeoc ooooccocexe + oexcoe ecccccoccxxexo + exxcx odoo exe c xcc + xox x xcxoeeo x cox + ece xcxddddddddxecxecee x xxx + xeeexcdc oee exeeox eex + ocx x eccccccc ceoddxeoeoe + oxxexo ooxeeoe + e xocoee eocdcoco + edecdccexddecccoecce + cx cdexeeceecce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_24.txt b/codex-rs/tui2/frames/codex/frame_24.txt new file mode 100644 index 0000000000..c0269d8eda --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_24.txt @@ -0,0 +1,17 @@ + + exedcccddoe + oceocxeexddcoecc + occdeoccx oedcxcco e + ocooooxdoeexoe ecexeec + o ooeoo eccoeexeeexoc + xoecee cooo oxd oce + x xx ooeoocoeexeexe + x exx xodoeexxeooexx + xo xddddccxxxccecoex x xx + e o cxoooddooxoeeccx xcx + e cexeccccccce eoocexdooe + e eoce x codo + eoceexo edceodec + oocoeecxxddddxeeoe + cdeccdeeeddcc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_25.txt b/codex-rs/tui2/frames/codex/frame_25.txt new file mode 100644 index 0000000000..5b040665d0 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_25.txt @@ -0,0 +1,17 @@ + + ecdcdcccce + o coceedexcxxo + oxoooocoxcedexxxe + xccx o dx cexoceo + oeeeoocedoexc xooeoc + eoxxxeccoexd oxoxooxo + xoxcx xeeoeeoxeoecxdx + xxxoxoc xedeoxeexdxxe + ecexcxeeddddcxxeexccxe + oocxoxoxexxdcexecdoex + excoexecccccccoxexoxe + xecxdcdeoocdeooooxo + eeexeeecdooeoexxo + eodeeecdxcc cxc + xoccecoecxc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_26.txt b/codex-rs/tui2/frames/codex/frame_26.txt new file mode 100644 index 0000000000..1592c09e8c --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_26.txt @@ -0,0 +1,17 @@ + + edccccco + ocxdoexcdxo + occcxdexecceo + dccoxxxexxecoe + xeoexoxcceodxed + e cxodocceeceeo + x ccdxxoxxddcc + oo exxxeedxxoxx + x oecdcxcddoexx + oexooxeeoceecx + xecoxcceooecexx + eexxoe oocxxe + c cxe eeoxoo + xcceecceccd + eodecxeec + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_27.txt b/codex-rs/tui2/frames/codex/frame_27.txt new file mode 100644 index 0000000000..5279157c04 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_27.txt @@ -0,0 +1,17 @@ + + dcccco + xddoxoe + dce cxocx + xxxexxdxx + x exeocd + xeoecexxxe + d cxxecxx + x exxxdcxx + xo o xcxxxx + cd ocexecxx + xo eecccoc + xxccxxeox + xddcdooxe + eeexedoo + cec eeo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_28.txt b/codex-rs/tui2/frames/codex/frame_28.txt new file mode 100644 index 0000000000..ea695865f4 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_28.txt @@ -0,0 +1,17 @@ + + occd + xcexe + d dxe + xoecx + x xx + x ocx + exx ex + xoccx + oe ex + xxodxx + x ex + xdcdx + xdcxx + ceeox + x ocx + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_29.txt b/codex-rs/tui2/frames/codex/frame_29.txt new file mode 100644 index 0000000000..328d426a41 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_29.txt @@ -0,0 +1,17 @@ + + ccccco + oxco ce + eoxx ccx + xxxxeeoo + e xcx x + xoxxx ee + xeexxx e + xxdxx + xxxcx e + exdxx e + cxxoxe d + xoxxx ex + xxxxexex + xdxcocxc + xxc oo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_3.txt b/codex-rs/tui2/frames/codex/frame_3.txt new file mode 100644 index 0000000000..3e9206577a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_3.txt @@ -0,0 +1,17 @@ + + eoddccddddoe + ecooexxcxcddxdeexcce + odocdxccce ecx cccoexo + ocoexdoce edc xxe + cocxoeoxxcee eeexxe + oxeeo ooxedee x eex + dc x ccexecxo ocoxo + ooxox ooxcoex xexdx + occx dxccxxcoxdcceeeccexecdx + oedeo oocoddx xcxeeo doodeexexe + cex x cxxcoc cccccccccoooooo + ccx ec e oeceoo + deooceo ocdocoxc + decoecceddddoddcdeecc + ecccedxeeeexdoec + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_30.txt b/codex-rs/tui2/frames/codex/frame_30.txt new file mode 100644 index 0000000000..b9da98c5c3 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_30.txt @@ -0,0 +1,17 @@ + + edcccco + eodxxeccde + ccexoeexcoe + xxexoxeexe eo + dcxxeexoxo x + xcxec cxxxxox + eoxxxee eoex de + cx ccdxoxcxo e + cxecexdxeoxo e + cxxexeexx co e + exxdeecxxxcxx + xcoooocexxc x + exexxocxxoxo + oeocdeoxooc + eooxeeedc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_31.txt b/codex-rs/tui2/frames/codex/frame_31.txt new file mode 100644 index 0000000000..baef07474c --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_31.txt @@ -0,0 +1,17 @@ + + eodccccdo + eccdcoeccecco + oxeooodeeocxece + oxoceecdeoeexexxe + exxxxcoceeocexoee + dxeexexccedcoooxocx + oxx xecxeododcxcxox + eeo xxcxe xeccxxeox + xeoexcexxxocxxxe x + cxxxooxxeeeeexexx c + eceocxo occceoxcxe + xxxeeeo edc x + dxcde o o xceoe + dxexoexeoxcoxe + ccdxeccoodc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_32.txt b/codex-rs/tui2/frames/codex/frame_32.txt new file mode 100644 index 0000000000..c0997d9a14 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_32.txt @@ -0,0 +1,17 @@ + + occccddxoe + dcxccxexxccxxo + oecdeocedoecxcecco + cooxeoedo o oeexco o + ooxoxceccxd ceoxeoeceo + eoeeoecxedxxce xco c + cxcdoecexxooxodeoeooxce + xxxoe cexxcocxdoecexcce + exxoe cexceexcccxxxdxcde + ccceexceceeeeeexxcxdxoe + oecxxo xccccccedooooo + eoxeee oexocx + cccxxxce eoexo o + eoecxcxddceecceo + xddeeococecc + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_33.txt b/codex-rs/tui2/frames/codex/frame_33.txt new file mode 100644 index 0000000000..cd8691c150 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_33.txt @@ -0,0 +1,17 @@ + + eocdcdcdxoe + eccxxecdxdxoxcxco + eoexcccodce ccoxxcco + oeoxoexoe cxxeec + eoxoexxoeee xceee + ooo eeeeeeo oeoee + xxc eocxexe xcxx + cxoo occeodo ecxc + xxx x oe ocooodddcxxcoexex + eoxccodooexxeeeeexxceeexxo + edeo xoxo o ccccccc xxooee + ececoco oododo + ceccocdo ecxoocec + exccecxodcecdoecxc + cddcoeeeeccdoc + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_34.txt b/codex-rs/tui2/frames/codex/frame_34.txt new file mode 100644 index 0000000000..ef8eabf7dc --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_34.txt @@ -0,0 +1,17 @@ + + eodccccdxoe + ecccoedxcxccdccdode + eoooxccdxce eeeeoccco + ooexceooe cxocee + ooxeoox xc o eecee + ooexeo eecxece eoexe + xcexe ceecxee xdxe + exdcd xcexocx o ee + ocooc oeooceddccccccxeec xee + xxeooooecoocxxxexoeeeooexxexe + oeeo xexce ccceeeee oooexc + ooeddee odoxoc + ecexcedo ecdceooe + oeoxcxcodocdcdceccdc + cxeddeeeeeddcde + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_35.txt b/codex-rs/tui2/frames/codex/frame_35.txt new file mode 100644 index 0000000000..1c53d2373f --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_35.txt @@ -0,0 +1,17 @@ + + eocddcccdcoe + ocdcoxccccxcxdcxcde + eooxdccxecce eccdcocxco + ooocoodoe cceexe + ooxxooe ceco xecxo + dodoce cxecooce xeex + e oo ee cceo xeee + xxxd oeedoc o co + o oe oxxodcoxddededcxx xxdc + xoedc oodcccoxd eoeeeeocxoc xc + oeeocoeexoee eceeeeceecooeox + coeeox eoc oe + edeedodo odoeccc + ceecxcxodxxcxdocceodc + cexddeeoeecccce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_36.txt b/codex-rs/tui2/frames/codex/frame_36.txt new file mode 100644 index 0000000000..4928a2a9d0 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_36.txt @@ -0,0 +1,17 @@ + + eecdccccdcoe + edccecodocecdcccccce + oeceoccoccee eccxccxocxo + exccceeee ccxecx + ooedecxeeeoo eeoc + o ooe ce cxoo ceec + x d e e cee xexo + xxex ee eoo ex x + xccx oo occcocceccccce xexx + ecxe oxeeoxo exeeeeeeexx eoce + c cxe cecoe ccceeeeec xoco + cocedo ooxco + eoxxcxo occcooe + coecccdedxecxdcocodcc + eccoxeooeooccccc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_4.txt b/codex-rs/tui2/frames/codex/frame_4.txt new file mode 100644 index 0000000000..a5ae50eeae --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_4.txt @@ -0,0 +1,17 @@ + + eoddcddcdcoe + eocccxo xedocdxcocoe + ocdxxxccce eexeecxoeee + ooxoxcoco cxccece + oxoexdxedexo ecexce + oxxex cexxoce c edxo + cexde ccceccxo o cdx + xoe oxocexx xxx + ex xe dxoceoocxccceeeeoo xxc + xeo x oooooexedexooeodoedxoocx + exdceoeeoeo ccccccccceoxxxe + oxecxee oedoccc + eeode oe eoeocdxo + xxxdecceddddocdccexce + ceexdxeeeeedoce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_5.txt b/codex-rs/tui2/frames/codex/frame_5.txt new file mode 100644 index 0000000000..47abf7a0af --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_5.txt @@ -0,0 +1,17 @@ + + eodddcdddcoe + ecccocoxxcxdcdexcco + ceoecxcce cedxce oco + oxoxoxodo oexxxxe + oeocoeecxeeo e exexe + xxxxo eexedoxe coxx + xox c eeexecxo ccxcxo + xoxec oedxoex cxex + xoxec oexcoxcdexcxeeecoecxox + eexeoooccc xc ooedeodoooxocxe + eoxxoxoeeoce ccccccccoxdxeo + eecoee e oeocoe + eocexdce edcoccdc + excxoccedxdeocdcexdc + eocecceeeoeocce + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_6.txt b/codex-rs/tui2/frames/codex/frame_6.txt new file mode 100644 index 0000000000..ba04c52772 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_6.txt @@ -0,0 +1,17 @@ + + eodddcddccee + oedocxdccxccdeocce + oooxxxcc ecxodcccoee + ooxcxcedo ecxxxxo + cdxxxceecce oeexeo + dooxoeecdxeexo odeox + e cxx eexoee ecxex + x xxx exdxoxx xcexx + eoxox ocxxcdocexcxeecceoeox + xxeooeeedc xodcoxxodddexoxe + o x oxxxdoc cccccccceeeox + o d ooe odeooxe + oeeecco eceeococ + ecxcocc dxeeoccc ddc + eodccoeeeeeocc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_7.txt b/codex-rs/tui2/frames/codex/frame_7.txt new file mode 100644 index 0000000000..f7dd0de9b6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_7.txt @@ -0,0 +1,17 @@ + + eoxdccddcoe + ocecexcccdded eco + c oxxxce eexe coeco + eexxxxdc execxoo + ecxcxxexoeo o cxxe + x dxxc ecedxe ooxxx + eecx eexexex ccexx + ecoxx dcoxcxe c xxx + ocxc oceoxeoxxddexxddcexx + xocxxddxxocxoexxeeododexex + e deocxceo cccccccceoxx + ce oeoee ocoodoc + cdoceeee oexococc + eecexcedxeeeccxcco + cxccxdxeeoedcc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_8.txt b/codex-rs/tui2/frames/codex/frame_8.txt new file mode 100644 index 0000000000..e3f93702f7 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_8.txt @@ -0,0 +1,17 @@ + + eecedccdcoe + occoxxxcdd x cc + exdoxooc ocxoxceceo + ececoxocx c dexxe + c oxooexoe ocexxo + xcoxeeeeexec ecxxx + xcxxx cedexex xexoxo + eoxxo xceoxoco oeoxx + e exxee ocdxxococooexxx + ecodexcoxoxxdxdeexxdc + ooeexexxxocececceccoex + cocxexee oooooc + ceeceeee eoeexcoc + odcxoc ddccdodoxe + xxeccexeocode + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_9.txt b/codex-rs/tui2/frames/codex/frame_9.txt new file mode 100644 index 0000000000..210e417d43 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_9.txt @@ -0,0 +1,17 @@ + + odecoccdo + oceoxxccd eoee + o oxxoceedo eexo + c xxodde eeoxeexco + occdeccxco coccdcxx + x xxxcexedee xcxcxxx + e xcexccxeeocooo exx + x xoeoexxxeodeex xx + coxc oxcxxxxcxxoxxe + xexxxeoxexexxxxeexxx + c eeoeexocccccxxcoex + exxxeoe oo eoxe + ecexee odedxoc + eoceexcocdddcxe + coe ceexdcoc + \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_1.txt b/codex-rs/tui2/frames/default/frame_1.txt new file mode 100644 index 0000000000..64a140d2b9 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_1.txt @@ -0,0 +1,17 @@ +                                       +             _._:=++==+,_              +         _=,/*\+/+\=||=_ _"+_          +       ,|*|+**"^`   `"*`"~=~||+        +      ;*_\*',,_            /*|;|,      +     \^;/'^|\`\\            ".|\\,     +    ~* +`  |*/;||,           '.\||,    +   +^"-*    '\|*/"|_          ! |/|    +   ||_|`     ,//|;|*            "`|    +   |=~'`    ;||^\|".~++++++_+, =" |    +    _~;*  _;+` /* |"|___.:,,,|/,/,|    +    \^_"^ ^\,./`   `^*''* ^*"/,;_/     +     *^, ", `              ,'/*_|      +       ^\,`\+_          _=_+|_+"       +         ^*,\_!*+:;=;;.=*+_,|*         +           `*"*|~~___,_;+*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_10.txt b/codex-rs/tui2/frames/default/frame_10.txt new file mode 100644 index 0000000000..9d45417346 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_10.txt @@ -0,0 +1,17 @@ +                                       +              _+***\++_                +             *'`+*+\~/_*,              +            ^_,||/~~-~+\,,             +           |__/\|;_.\,''\\,            +           / ;||"|^  /_/|/            +          |` '|*~//\   `_"|            +          \  ~*"*||~|*   |/,           +          "  ||\+/+||-_ .\||           +          "  ~\ \\|;~~+\+;||           +          |  ,|\,|_/_*___|*`           +           , "|||||""!\,"\|`           +           \`',\,*"  "",//            +            |' |||~*,:,/|/`            +             ;`**/|+;_!//'             +              *, _*\_,;*               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_11.txt b/codex-rs/tui2/frames/default/frame_11.txt new file mode 100644 index 0000000000..769e5ae76d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_11.txt @@ -0,0 +1,17 @@ +                                       +               ,****++_                +              /" ;|||\\,               +             /"__||;\*/\,              +             |__||=;,_=//              +            _".;\|+\';_||,             +            |+`||+_;;|_/||             +            ** ,||||||_|=\             +            |  ||/||\/ |"|             +            /  '|/||*/+|_|             +            ** _|/=,"/|_|^             +            '`- ||||=/|\\|             +             \_-/|_*/**;|`             +             !_ *|\\^_|;"              +              \+!*||,_/*`              +               \_ '*+_+`               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_12.txt b/codex-rs/tui2/frames/default/frame_12.txt new file mode 100644 index 0000000000..50cfd73302 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_12.txt @@ -0,0 +1,17 @@ +                                       +                +***+.                 +               ,=`_/|,\                +               "  |/\+,                +               /+~||=="|               +              | '~|||./|               +              |'..*^"_|"               +              |   ~/\||\               +              |   /+\||"               +              *, ~/||+|~               +              |   /|*;*_               +              |.  |"=**/               +               ,  *|!_,|               +               / **|,*\|               +               '^/",|\|`               +                \ '~\./                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_13.txt b/codex-rs/tui2/frames/default/frame_13.txt new file mode 100644 index 0000000000..04ed71335c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_13.txt @@ -0,0 +1,17 @@ +                                       +                 /***,                 +                 |__||                 +                 |`_|"                 +                 |**|_                 +                 *  ||                 +                 ":-||                 +                 ,  ||                 +                 +  "|                 +                /+  _~.                +                |"  +=|                +                '`.. ~`                +                 |___|                 +                 |+,|_                 +                 *__|=                 +                 , ."=                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_14.txt b/codex-rs/tui2/frames/default/frame_14.txt new file mode 100644 index 0000000000..66e91f7187 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_14.txt @@ -0,0 +1,17 @@ +                                       +                 +***;                 +                 ,/__.\                +                |_||. |                +                ||/|"^~,               +                |||\   |               +                ~*||  '|               +                |||| . *               +                ||\|`  \               +                |||~   "               +                "^//  ;/               +                \|"",.,|               +                |*~|___|               +                /!"|===`               +                |\/*__/                +                 _* '=/                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_15.txt b/codex-rs/tui2/frames/default/frame_15.txt new file mode 100644 index 0000000000..9d8132e3c4 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_15.txt @@ -0,0 +1,17 @@ +                                       +                 ++***~_               +                `,=||^:*,              +               //|*=\|"*|              +               |/` //,.__|             +              ||="=\||/"^|             +              \||-||//|  "             +              ||   ||||~~,|            +              ||/~|+||| '-|            +              ||+,,*|||_.:|            +              |_|;/|\~*. .|            +              |/||||_| ` ;             +              |**.^~|\-  =             +              '|\, ///` ;`             +               |^||\\.+\/              +                \^*^___/               +                   ``                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_16.txt b/codex-rs/tui2/frames/default/frame_16.txt new file mode 100644 index 0000000000..7217fe58b8 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_16.txt @@ -0,0 +1,17 @@ +                                       +                _=+"**+~_              +               /^||*||\|=\             +              |//"\/=\|| '\            +             /// ;' \|\||**|           +             ||;_ =||*/|`\           +            |||*|  /|= !| ~.|          +            \\|  ,||||/*", |          +            |/; |`||/|||"; `|          +            \\|~|+~/^||"*+  /          +            *"__,==\*|._| ,_|          +            |||+""/*\|;";.~|`          +             ||* |   `//,  /           +             \|*  |  /,/_,|            +              \|~"_*~//+_|             +               ':._=:__;*              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_17.txt b/codex-rs/tui2/frames/default/frame_17.txt new file mode 100644 index 0000000000..0d873df751 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_17.txt @@ -0,0 +1,17 @@ +                                       +                ,=+++;;~,_             +             _;**|~~*=*|,"^,           +            ,*\/_==`+,"|||_"\          +           /|/_/|"   |;\~||=\         +           |/_ ~     "/\=\//  ,        +          `=*,/`   ,:/| /,=/|./        +          *!;/|   ,//|_ *"||/=|        +          -"=|!   !//||/ ,||=;*        +          ,/*/\==+~\_|\^:\||| |        +           |"_;__|/*\/||\!\+'+\        +          \\\/"""****\_|*//\ \'        +           \||_*       `/||` ;         +            _\\!*\_   ,',/^_/          +             , ~*+=\+`_;*:|'           +              `+;/_,+~*_+*             +                   `                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_18.txt b/codex-rs/tui2/frames/default/frame_18.txt new file mode 100644 index 0000000000..a474a4f3d0 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_18.txt @@ -0,0 +1,17 @@ +                                       +               _==+==+;~,_             +            _+"_;,++~__,"+;;_          +          _/:|*"*=" "._"+//\ *         +         ,||*.,^" _/=~\;\\\,       +        _\// /|   _\/~/|_\;\\_       +        /\| ,, _/,*,|'-/^/`/~!      +        ||\:/     +/*/|"_/"*|=|=,      +        "\-~|     ^\"||;^   |;|"       +       \"" ,\==;=;+~|,|*/\, |*|`       +        _\\|*\* ~|/__\_~||_;~`\ ,      +        |/|\`""*****` \__/|/*/`-`      +         \\!,\,         ``*"/*_'       +          \^*+^^.      _*^_/\,`        +           '|.**++===^'*_/_,*          +             "~|_/__,.+";+"            +                    `                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_19.txt b/codex-rs/tui2/frames/default/frame_19.txt new file mode 100644 index 0000000000..e83b78bd3b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_19.txt @@ -0,0 +1,17 @@ +                                       +              __==+~+==~._             +           _+|||\/===_*_,|*,,          +         ,*;;/"|+*`    `*:,`*;\        +        /;|*,^`         _==+,^|*,      +       ||/`/`         ,|^"/"|\ |\,     +      |\/*'         _|*~/!./ '.*|      +      ^=|~'        /*^/|+,`   /:/+|    +      ||||         |;"\|",    | |||    +      |*|\,=;;===~~~|+*;*|;   \ "||    +      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     +      '\"/+|"""""**"   ^\_/|// //'     +       \\^*_\             `//_//'      +        '!_\~~*,        ,;=./|;`       +          '".|*/~+__=;/*" ;*~'         +             "~._:-'_,.^*-^            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_2.txt b/codex-rs/tui2/frames/default/frame_2.txt new file mode 100644 index 0000000000..ac205dd4a5 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_2.txt @@ -0,0 +1,17 @@ +                                       +             _._:=+===+,_              +         _+,/*;+||~=~|=_'|*+_          +       ,|*|\**~*``  `"*=*/||;|,        +     _|/_/*',,_           -,\|/|,      +     ,^"/*^|\_\\_           ^,|\|,     +    '/+"`  |*\+/\+           \\|*|     +   ,|'*|    ^/|;|_|,          /"|"|    +   |" \`     ,|*||;*          |'/.|    +   *""=|    ;|^^_|".~++++++++,|_|~*    +    _='|  /||' /* |=\____..__|+/!|!    +    \\\ * *\..|'   `"*******"|,*||     +     '\_./, `              ,+;/,*      +       .\__|+,          ,=_+!_|"       +        `~^;__"*+:;=;;.=*`_||*         +           `*+*+~~____~;/*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_20.txt b/codex-rs/tui2/frames/default/frame_20.txt new file mode 100644 index 0000000000..bff8cc065f --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_20.txt @@ -0,0 +1,17 @@ +                                       +              __+=~~=+=,__             +          ,;=";,_,===||_^*\+,          +        ,,"_+*"~*"    `"^"\+*|+_       +      _|"_*|*           _,+|\/*\\      +     _| ,|*           _/!_||^*\||\     +     /`,|'           +*';|"/  '\~|\    +    ;|;+|          ,* .+*^     ,\|/    +    |_^/`          "";:|",     |~"|    +    |/||;,=;======_,';^^\\*    / |\    +    '|^|_" /``____|\  *\\"|=\ ,/ ||    +     \,^\'"""""""*"     \,=||,| /\     +      * ^*/,               _/*.|/      +        \_^*,~_          ,;*`;*'       +          ^,-."~+^;;,:"~"`,/*'         +            `*+~_!=____|*""            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_21.txt b/codex-rs/tui2/frames/default/frame_21.txt new file mode 100644 index 0000000000..b23aadbc7c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_21.txt @@ -0,0 +1,17 @@ +                                       +             __,=+==+=+,__             +          ,+*``__+~=~+;_`-*+_          +        ;*^_+*^"`     `^~*+_`*,        +      ,*|;"'             _,;*,;*,      +     /,/*`             ,|/_\ \\_^,     +    /"/|             ,**_//    \\^,    +    |'|`           _!/`,/'     \;\"    +     `\            \; "|,       | |    +    | |  ,+;;;;;+++  ^|,"|,    ;/ |    +    | ~\ |_      _,|   ^"`*|,  /"./    +     ; ". ``"""  '`     '*_,; /` ,     +     '+ :;_                 _*" /      +       *_  ^-_          _.;*' ,`       +         *=_ *"*+:~~;:=*""  -`         +            *++;+____,_;+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_22.txt b/codex-rs/tui2/frames/default/frame_22.txt new file mode 100644 index 0000000000..ccc8480d8b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_22.txt @@ -0,0 +1,17 @@ +                                       +             _,+=+==+=+,_              +         _+/*|._/|/\||;|+/*+_          +       ,;*;~\**`    `"*\**+__+,        +      ;*~**"            ,,|||_*\       +     \|~/'            ,_/|=|./;'|      +    \|*/`           ,~/|;^/` \|\=|     +   |+*\/           ~+|\*/'    ~|/|     +   ||=|`           |\|~^\     |^|"|    +   ||,", +~==;;;=++_\*|_!\,   _|;"!    +   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     +    \\\_| """""**"`    `:*+_///",`     +     \*\_\,               _*,"|,'      +      '"+; ++_         _.*,"*_/        +        ':*+,"*~;=+;;/=*""',*          +           "~"~_;___-=_.=*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_23.txt b/codex-rs/tui2/frames/default/frame_23.txt new file mode 100644 index 0000000000..406ced01b0 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_23.txt @@ -0,0 +1,17 @@ +                                       +            _,+_=+*++=+__              +         _=|+|\_,_==,|_|*|+_           +       ,"+|',;*`    "~:+|\;||,         +      /;|*;/`          _;;\||;\        +     //|`/'          ,/,,'*/*\|\       +    ,\|"/`         _***''/*'|~\|,      +    `||"|         /:/. _|`  "!|'*      +    ~/| |         |"|,_\,   | */|      +    ^"\ |+~;=====;=|_*|_"\_ | |~|      +    |\\_|"="       /`\ \|_\,~ \_|      +     /'| | `"""""""   '`/;=|_/^/`      +      ,||_|.             ,/|\^/`       +       \ |,'/__       _.";*/+/         +         \=\+;*+\~==_++"-_+*`          +           "~!*=\~__+__**`             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_24.txt b/codex-rs/tui2/frames/default/frame_24.txt new file mode 100644 index 0000000000..73f5639390 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_24.txt @@ -0,0 +1,17 @@ +                                       +             _~_;++*==,_               +          ,"_/"|__~==+,_'+             +        ,"';\/+"~ .`:*~**, \           +       ,'//,/|=,\`~/`!_*\|\_'          +      , //\/,    `""/\_|``\|,'         +      ~,\+\`       *,,/!.|;!/"\        +     |  |~!      ,/_,/"/^\|\^|^        +     | \||       |,=/\_|~_/.`||        +     |. |;;;:++~~~++_*,_| |  ||        +      _ / !*|/,,;;,,|.^\+*| |*|        +      \ '\|\*""""""` \,.*\|=/,`        +       \ \,*\           |!"/;/         +        \.*\`|.      _="_/:_*          +          .-*,\^"~~:==;|^_/`           +            *:_*+;\__==+*              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_25.txt b/codex-rs/tui2/frames/default/frame_25.txt new file mode 100644 index 0000000000..6fb0cbc16c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_25.txt @@ -0,0 +1,17 @@ +                                       +             _+=*;++++_                +           ,!*,*`_;\|*||,              +          /|//,,".|+\=\|||_            +         |+*| /! =| "\|,*\/            +        ,__\,/'^;/_|" |//\/*           +        \,~|~_*+.^|: /|,|//|,          +        |/|*| |__/\_/|\/_"|=|          +        |||/|."!~_=\/|\_~=||\          +       ^+\|"|__====+~|\\|+*|\          +        /-"|/|,|_||;*_|\*=/\|          +        \~*/\|`"""""'+/|\|/|`          +         |_"|;+;\-,*:_/,//|/           +          ^`^|_\_*;/,^/_||/            +           \.;\\_*=|**!*|*             +             ~,"*\+,_+|*               +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_26.txt b/codex-rs/tui2/frames/default/frame_26.txt new file mode 100644 index 0000000000..8bd6052839 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_26.txt @@ -0,0 +1,17 @@ +                                       +              _;***"+,                 +             /*|;/\|+;|,               +            /*""|;\|\""\,              +           ;*",||~_||_+/^              +           |_,\|.~"*\/;|\;             +           \ "|/:/"*_\"\\/             +          |!  *'=||/||;;"+             +          /-  \|||^^=||/||             +          |  .\*;+~+==/\||             +          ! ._|/,|__,*\\*|             +           |`"/|*"\,/`+\||             +           \_~|/\   //"||`             +            *  *|\ ^`/|/,              +             |"*\\"*_**;               +              \/:^*~_\*                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_27.txt b/codex-rs/tui2/frames/default/frame_27.txt new file mode 100644 index 0000000000..e8630695b8 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_27.txt @@ -0,0 +1,17 @@ +                                       +                ;***+,                 +               |;:/|/\                 +              ;'` *|/'|                +              |~~^||;|~                +              |  `|_-'=                +              ~_._"`|||`               +              = "||_*||!               +             |  `||~="||               +             |. .!|+||||               +             '= ."_|_*||               +              |- _^**+/'               +              ||++||_/|                +              |==+=,/|^                +               \__|\=//                +               '\" ^\/                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_28.txt b/codex-rs/tui2/frames/default/frame_28.txt new file mode 100644 index 0000000000..3313d8b9bf --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_28.txt @@ -0,0 +1,17 @@ +                                       +                 /**;                  +                 |+_|`                 +                 = ;|`                 +                 |-`*|                 +                 |  ~|                 +                 | -"|                 +                ^|~ _|                 +                 |-""|                 +                /\  _|                 +                ||.:~|                 +                 |  _|                 +                 |=+=|                 +                 |=*||                 +                 *__/|                 +                 ~ .+|                 +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_29.txt b/codex-rs/tui2/frames/default/frame_29.txt new file mode 100644 index 0000000000..2ae088f1b9 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_29.txt @@ -0,0 +1,17 @@ +                                       +                 +****,                +                ,|*/!*\                +                ^,|| '"|               +                ||||^\,/               +                \ ~"|  |               +                |,~|| __               +                |\\||| ^               +                ||=~|                  +                ||~*|   `              +                _|=||   `              +                *||/|^ =               +                |/~~| _|               +                ~|||`~_|               +                |=|*/"|'               +                 ~|* .,                +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_3.txt b/codex-rs/tui2/frames/default/frame_3.txt new file mode 100644 index 0000000000..727e25a8e8 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_3.txt @@ -0,0 +1,17 @@ +                                       +             _.=;++====,_              +         _+,/\||+|"==|;_^|*+_          +       ,;/*;|*""`   `"~!**+/^|,        +      /+/\|;,+_          `=*!||\       +     '/*|/^/||*\_           \^\||_     +    /|\\/  .,|\;\\           | \\|     +    =* |    '*\|_"|,          .*/|,    +   ,-|,|     ,-|"/\|          ~_|=|    +    -"'|    ;|*+~|*-~=++___++_~^";|    +    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    +    '^| | "||+,"   "***"""**,///,/     +     '*~ \+ `              /^+^//      +       =^//*\,          ,+;/",|'       +         =^+,_**^=;=;,:=*;`_+"         +           `*+*_:~____~;/^"            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_30.txt b/codex-rs/tui2/frames/default/frame_30.txt new file mode 100644 index 0000000000..99eeebce33 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_30.txt @@ -0,0 +1,17 @@ +                                       +                 _;"**+,               +               _/;||\*'=\              +               "'^|,\\|+,\             +              ||\|/|_\|\ \,            +              =*||`\|,|,  |            +             |*|^+  *||||.|            +             \/|||\_ \/\| =`           +             "| '+=~,|"|-  `           +             "|_"\~=~\/|,  `           +             "||\|__~|!+,  `           +             !\||;\_*~||+~|            +              |*//,/*\||" |            +              \|\||/*~|,~/             +               ,^,+=^/|,/'             +                \-.|^__;'              +                  ````                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_31.txt b/codex-rs/tui2/frames/default/frame_31.txt new file mode 100644 index 0000000000..8d9adf28b2 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_31.txt @@ -0,0 +1,17 @@ +                                       +                _.:*+*+=,              +              _+*;+,_"+\'*,            +             ,|\//,=_`."|_*\           +            ,~/+__*;_,\\|\~|\          +            ^||||+-*\_,"\|/__          +           ;|^`~_|'"\;*,./|,"|         +           /|| |^*|\.=/;*|*|/|         +           \\, |~"|\ |^""|~\.|         +           |\,_|'^~|~/+~~|_  |         +           "||~//||___\_|\|| *         +           ^*_/+|, /***`/|'~_!         +            |||\\\,     _=* |          +             ;|*=_!,  . |*`/`          +              :|\|/_|`,|",|`           +               '"=~_+*/.;*             +                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_32.txt b/codex-rs/tui2/frames/default/frame_32.txt new file mode 100644 index 0000000000..4175a7a66e --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_32.txt @@ -0,0 +1,17 @@ +                                       +                ,++++;;~,_             +              ;*~**~\||**|~,           +            /^*=^,+^:-`*|*\'*,         +           '//|_,`;- - ,_\|+,!,        +          //|,|*\'*|; '`,~\/\*\,       +          \/\\,\*|`:||+_   |+/ *       +         *|";,`'\||,,|,=`/_//|'_       +         |||,_  "_||"/'|;-_"\|""`      +         \||-_ '_|"__|+++~~~=|"=`      +         '""_`|*\'\_____||*|;~-_       +           ,\*||/ |*""***^;///./       +           \,|^\\        ,\|/'~        +           '**||~+_    _/_|/ ,         +             \-\"~+|;=*`_+"_/          +               ~;=__,+/*_""            +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_33.txt b/codex-rs/tui2/frames/default/frame_33.txt new file mode 100644 index 0000000000..dbd9568018 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_33.txt @@ -0,0 +1,17 @@ +                                       +               _,+=+=+=~._             +            _+*||\*=~:|-|*|*.          +          _/\|+*+,="`  "*/||+",        +         ,\/|/_|,_        *||\_*       +        _.|/`||/\^\         |+\\^      +        //,   \_\\`\,        /\/_\     +        ||+ !  \,*|_|\       |*||      +        *|,,   ,''\/=,       \"|*      +        ||| | ,` /*,,,;==+~~+/_|\~     +        ^/|'"/:,/`~|_____||*^^^~|.     +        \=\, |/|/ / """"*** ||,/^`     +         \+\*,",           //;/=/      +          *\"*,*;,      _+|,/*\'       +            \~"*\+|,;+_";,\*~*         +              "==+,___^+*;-"           +                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_34.txt b/codex-rs/tui2/frames/default/frame_34.txt new file mode 100644 index 0000000000..7fc67a92db --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_34.txt @@ -0,0 +1,17 @@ +                                       +               _,=++++=~,_             +           _+*+/\;|"~+*=**;,=_         +         _//,|*":~*`   `^\\,"**,       +        ,/_|*_/,_          *|,*\\      +       //|\-/|!|"!,          \\*\^     +      ,/\|`/ \\"|\*\          \,\|\    +      |*^~_   '\_*|_^          |;~_    +      \|=":    |*_|/"|         / _\    +      ,'/."   /\//"_==++++++~__" ~^`   +      ||\,/,,^"//'~||_~.___,/_||`|\    +       /_\, |\|*^!  "**````^ ,/,\|'    +        /,\;=\_             /;.|/'     +         \+`|+\;,        _+="_/,`      +           -\/~"|+,;,+=*=*`+*:'        +             "~\;=_____;=*=^           +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_35.txt b/codex-rs/tui2/frames/default/frame_35.txt new file mode 100644 index 0000000000..570f34f0de --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_35.txt @@ -0,0 +1,17 @@ +                                       +              _,+;=+++=+,_             +           ,*=*.~**+"|*~:"~*=_         +        _//|;+*|^*"`  `"*=*."|*,       +       ,//+/,;,_            *+_\|_     +      //~|//\ "\*,            |\*|,    +     ;/;/+` '|_"..*_           |\\|    +     \ !./    \\ '*_.           |^\\   +     ~|~:      /\\;/'           / "/   +     / ,_    ,||/;"/|==_;_=+~~  |~=*   +     |,^;'  //;"*+/|; \,____/"~/' |'   +      /`\,'/\_|/^`  `"^^^^*^^*//_,|    +       ".^\/~               _/* ,^     +        \;`^;,:,          ,;/_**'      +          "^_"~*~-:~~+~;/*"_/;"        +             "^~;=__/__++*"^           +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_36.txt b/codex-rs/tui2/frames/default/frame_36.txt new file mode 100644 index 0000000000..74d83c8e70 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_36.txt @@ -0,0 +1,17 @@ +                                       +              __+=++++=+,_             +          _=""\+/;/+\+;++"**+_         +        ,\'\,+*-*"`` `"*~*+|,*|,       +      _|"*+____            '*~\"|      +     ,/_;\'|\`\,.             ^\.*     +     / ,/`  *_ "|/,            "\^*    +    | ;!`     !\ "\\            |^|,   +    ||\~      _\ _//!           \| |   +    |'"|     // ,*"',++_+++++_  |\~|   +     _*|\  ,|__/~/ !`~_______|| \/'`   +     ' *|\ +_+/^     "**^^^^^" |,"/    +      ',"\;.                 ,/|"/     +        \/||+~,           ,++"/,`      +          *,_"**=^;~_+~;"-",;+'        +            `*+/~_,,_,,++**"           +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_4.txt b/codex-rs/tui2/frames/default/frame_4.txt new file mode 100644 index 0000000000..06dbce99c0 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_4.txt @@ -0,0 +1,17 @@ +                                       +             _.=;+==+=+,_              +         _-"+*|/!|\=/*;|"/*,_          +       ,*=|||+*"`   `^~\^*|/\\_        +      //|,|".+,          "|**\*\       +     /|/_|=|\;^|,          \"\|*\      +    /||^|  '_||/*\          ' \=|,     +    "\|;`   '**\+"|,         , ";|     +     |.\     ,|/*^||           |||     +    _|!|_   ;|/"^//+~+++____,, ||*     +    |\/!| ,///,_|`=\|,._,:/^;|//"|     +     `|;'\,\\,\/   "*******'^-|||`     +      ,|\"|_`             ,^;/**'      +       \\,:^!,_        _.^,*;|/        +         ~||=_**\;;=;,+=*+\|*`         +            *\\~:~_____;-*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_5.txt b/codex-rs/tui2/frames/default/frame_5.txt new file mode 100644 index 0000000000..6b1ce12447 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_5.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+===+,_              +         _+"+/*/||+~=+;_|"+,           +        *_/\+|*"`   "^=|*\!,*,         +      ,|/|/|.=,          -^||||_       +     ,\/*/^_+|_\,         ` \|\|_      +     ~|||/ \_|\;/|_          +/||      +    |/|!+   `\_|\"|,        ''~+|,     +    |/|_"    ,_=|/_|          +|_|     +    |,|^*   /_|",|*=_~+~___+,_"|.|     +     _\|\,,/+*" |"!//\=^,=.,/|/*|`     +     \,||,~,\\/+`  "**""""",~;|\/      +      \\+/\\ `            /_/*,^       +       ^/*\|=+_        _=+,*';*        +         ^|*|,*+\;~=_,+=*^~;*          +            ^-*_*"___-_,+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_6.txt b/codex-rs/tui2/frames/default/frame_6.txt new file mode 100644 index 0000000000..7724f483dc --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_6.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+==++__              +          ,^;/*|=*+|++;_,*+_           +        ,,,|||*"   `"~.=*+*/\_         +       /,|*|*_=,        \+||||,        +      '=|||*\\+*\         .\\|\,       +     ;-/|/`^+;|\_|,        .=\/|       +     _ +~|   !^\|/^\        ^+|_|      +     ~ |~|   _|=~/||        ~+\||      +     _.|-|  /'||*;/+_~+~__++_.\/|      +     ||\,/_^_;+ |/=*,||,;==\|,|\!      +      / | /~||;/"  "*"""'*"`\\/|       +       , ; /,`           ,:_//|`       +        .`\_**,       _+^_/*,+         +         `"~*,"+!;~__,+**!:;'          +            ^-;*+,___`_,+*             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_7.txt b/codex-rs/tui2/frames/default/frame_7.txt new file mode 100644 index 0000000000..0d0f43072c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_7.txt @@ -0,0 +1,17 @@ +                                       +             _.~;*+==+,_               +          ,*`+\|+*+==\;!`*,            +         * ,|||*`  `^~`!*/_*,          +        \\|||~;+       ^~\*|/,         +       `'|*||^|/\,       . *||\        +      | ;||" `'\;|\       ,-|||        +       `\"|  ^_|\|\|       **^||       +      _"/~|   =+/|*|`      ' |||       +       /"~* ,"_/|\/~~;;_~~=;*\||       +      |,'||=:~|/'|.\||\\,=,;\|\|       +       \ =^/*|*_/  ******""_/||        +       '_ /_/_`         ,"-/;/'        +        ';,+\\\_      ,^~,*/*'         +          \_'\|*\;~___+*|*+/           +            "~*"~:~__,_;**             +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_8.txt b/codex-rs/tui2/frames/default/frame_8.txt new file mode 100644 index 0000000000..2e8019c061 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_8.txt @@ -0,0 +1,17 @@ +                                       +             __+_;++=+,_               +           ,"*/|||*==!~ "+             +         _|=,|//*!,"~/~*\+^,           +        _*\*/|,+|     ' =^||\          +        ' /|/,\|/\      .'\||,         +       |',|\^^_\|_*      \+|||         +       |*||| '_;\|`|      ^|/|,        +       \,||/  |+\/|,*.    .`/||        +       \ \||_^ !/*=|~/+,+,,\~||        +       !  \*/=_|",|,||;|=__||='        +        //\\|\|||/'^*^*"\*"/\|         +        ',"|\|``        .,///'         +         '\_*\\\_    _/\_|+/*          +           .:'|,*!;;+*;/=,|`           +             ~~_**\|_,+/=`             +                  ``                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_9.txt b/codex-rs/tui2/frames/default/frame_9.txt new file mode 100644 index 0000000000..128e915007 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_9.txt @@ -0,0 +1,17 @@ +                                       +              .=^*/++=,                +            /*_/||*"=!_-\_             +           / ,||/*^^=/!\_~,            +          " ||/;=_ _^,|\^|+,           +         /*";\*"|*,  +:+||           +         | |||"^|\;*   '|*|||          +         ` ~*\ **|\\," / `||          +        |  ~/_ ~||_/= | !||          +        !  ",|" /|"|~~|+|~,||`         +         |_|||_,|^|_||||__|||          +         " `^/\\|/"****||"/\|          +          \~||\,`     ,/ ^/|`          +           \+\|\\    /;_;|/*           +            ^-"\_|*/+=;;*|`            +             '-_ *\\|;+/"              +                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_1.txt b/codex-rs/tui2/frames/dots/frame_1.txt new file mode 100644 index 0000000000..36964a4864 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_1.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●●○○●●○ + ○○●◉●○●◉●○○··○○ ○ ●○ + ●·●·●●● ○· · ●· ·○···● + ◉●○○●●●●○ ◉●·◉·● + ○○◉◉●○·○·○○ ◉·○○● + ·● ●· ·●◉◉··● ●◉○··● + ●○ ◉● ●○·●◉ ·○ ·◉· + ··○·· ●◉◉·◉·● ·· + ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · + ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· + ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ + ●○● ● · ●●◉●○· + ○○●·○●○ ○○○●·○● + ○●●○○ ●●◉◉○◉◉◉○●●○●·● + ·● ●···○○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_10.txt b/codex-rs/tui2/frames/dots/frame_10.txt new file mode 100644 index 0000000000..3c687d7f64 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_10.txt @@ -0,0 +1,17 @@ + + ○●●●●○●●○ + ●●·●●●○·◉○●● + ○○●··◉··◉·●○●● + ·○○◉○·◉○◉○●●●○○● + ◉ ◉·· ·○ ●●◉○◉·◉ + ·· ●·●·◉◉○ ·○ · + ○ ·● ●····● ·◉● + ··○●◉●··◉○·◉○·· + ·○ ○○·◉··●○●◉·· + · ●·○●·○◉○●○○○·●· + ● ····· ○● ○·· + ○·●●○●● ○· ●◉◉ + ·● ····●●◉●◉·◉· + ◉·●●◉·●◉○ ◉◉● + ●● ○●○○●◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_11.txt b/codex-rs/tui2/frames/dots/frame_11.txt new file mode 100644 index 0000000000..c2548db4b3 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_11.txt @@ -0,0 +1,17 @@ + + ●●●●●●●○ + ◉ ◉···○○● + ◉ ○○··◉○●◉○● + ·○○··○◉●○○◉◉ + ○ ◉◉○·●○●◉○··● + ·●···●○◉◉·○◉·· + ●● ●······○·○○ + · ··◉··○◉ · · + ◉ ●·◉··●◉●·○· + ●● ○·◉○● ◉·○·○ + ●·◉ ····○◉·○○· + ○○◉◉·○●◉●●◉·· + ○ ●·○○○○·◉ + ○● ●··●○◉●· + ○○ ●●●○●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_12.txt b/codex-rs/tui2/frames/dots/frame_12.txt new file mode 100644 index 0000000000..30b03392bf --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_12.txt @@ -0,0 +1,17 @@ + + ●●●●●◉ + ●○·○◉·●○ + ·◉○●● + ◉●···○○ · + · ●····◉◉· + ·●◉◉●○ ○· + · ·◉○··○ + · ◉●○·· + ●● ·◉··●·· + · ◉·●◉●○ + ·◉ · ○●●◉ + ● ●· ○●· + ◉ ●●·●●○· + ●○◉ ●·○·· + ○ ●·○◉◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_13.txt b/codex-rs/tui2/frames/dots/frame_13.txt new file mode 100644 index 0000000000..cb95f3763d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_13.txt @@ -0,0 +1,17 @@ + + ◉●●●● + ·○○·· + ··○· + ·●●·○ + ● ·· + ◉◉·· + ● ·· + ● · + ◉● ○·◉ + · ●○· + ●·◉◉ ·· + ·○○○· + ·●●·○ + ●○○·○ + ● ◉ ○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_14.txt b/codex-rs/tui2/frames/dots/frame_14.txt new file mode 100644 index 0000000000..3a8ed60b8f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_14.txt @@ -0,0 +1,17 @@ + + ●●●●◉ + ●◉○○◉○ + ·○··◉ · + ··◉· ○·● + ···○ · + ·●·· ●· + ···· ◉ ● + ··○·· ○ + ···· + ○◉◉ ◉◉ + ○· ●◉●· + ·●··○○○· + ◉ ·○○○· + ·○◉●○○◉ + ○● ●○◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_15.txt b/codex-rs/tui2/frames/dots/frame_15.txt new file mode 100644 index 0000000000..c57b4af0ee --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_15.txt @@ -0,0 +1,17 @@ + + ●●●●●·○ + ·●○··○◉●● + ◉◉·●○○· ●· + ·◉· ◉◉●◉○○· + ··○ ○○··◉ ○· + ○··◉··◉◉· + ·· ······●· + ··◉··●··· ●◉· + ··●●●●···○◉◉· + ·○·◉◉·○·●◉ ◉· + ·◉····○· · ◉ + ·●●◉○··○◉ ○ + ●·○● ◉◉◉· ◉· + ·○··○○◉●○◉ + ○○●○○○○◉ + ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_16.txt b/codex-rs/tui2/frames/dots/frame_16.txt new file mode 100644 index 0000000000..18ae0e09ee --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_16.txt @@ -0,0 +1,17 @@ + + ○○● ●●●·○ + ◉○··●··○·○○ + ·◉◉ ○◉○○·· ●○ + ◉◉◉ ◉●○○·○··●●· + ··◉○◉●●○··●◉··○ + ···●·● ·○○ · ·◉· + ○○· ◉·●····◉● ● · + ·◉◉ ····◉··· ◉ ·· + ○○···●·◉○·· ●● ◉ + ● ○○●○○○●·◉○· ●○· + ···● ◉●○·◉ ◉◉··· + ··● ·· ·◉◉● ◉ + ○·● · ◉●◉○●· + ○·· ○●·◉◉●○· + ●◉◉○○◉○○◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_17.txt b/codex-rs/tui2/frames/dots/frame_17.txt new file mode 100644 index 0000000000..a470b4ba8d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_17.txt @@ -0,0 +1,17 @@ + + ●○●●●◉◉·●○ + ○◉●●···●○●·● ○● + ●●○◉○○○·●● ···○ ○ + ◉·◉○◉· ○· ●·◉○···○○ + ·◉○ · · ◉○○○◉◉ ● + ·○●●◉· ●◉◉· ◉●○◉·◉◉ + ● ◉◉· ●◉◉·○ ● ··◉○· + ◉ ○· ◉◉··◉·●··○◉● + ●◉●◉○○○●·○○·○○◉○··· · + · ○◉○○·◉●○◉··○ ○●●●○ + ○○○◉ ●●●●○○·●◉◉○ ○● + ○··○● ·◉··· ◉ + ○○○ ●○○ ●●●◉○○◉ + ● ·●●○○●·○◉●◉·● + ·●◉◉○●●·●○●● + · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_18.txt b/codex-rs/tui2/frames/dots/frame_18.txt new file mode 100644 index 0000000000..c0354b3933 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_18.txt @@ -0,0 +1,17 @@ + + ○○○●○○●◉·●○ + ○● ○◉●●●·○○● ●◉◉○ + ○◉◉·● ●○ ● ◉○ ●◉◉○ ● + ●··●◉●○ ● ●○·○◉○·○◉○○○● + ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ + ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· + ··○◉◉ ●◉●◉· ○◉ ●·○·○● + ○◉·· ○○ ··◉○ ·◉· + ○ ●○○○◉○◉●··●·●◉○● ·●·· + ○○○·●○● ··◉○○○○···○◉··○ ● + ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· + ○○ ●○● ··● ◉●○● + ○○●●○○◉ ○●○○◉○●· + ●·◉●●●●○○○○●●○◉○●● + ··○◉○○●◉● ◉● + · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_19.txt b/codex-rs/tui2/frames/dots/frame_19.txt new file mode 100644 index 0000000000..c9ded56838 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_19.txt @@ -0,0 +1,17 @@ + + ○○○○●·●○○·◉○ + ○●···○◉○○○○●○●·●●● + ●●◉◉◉ ·●●· ·●◉●·●◉○ + ◉◉·●●○· ○○○●●○·●● + ··◉·◉· ●·○ ◉ ·○ ·○● + ·○◉●● ○·●·◉ ◉◉ ●◉●· + ○○··● ◉●○◉·●●· ◉◉◉●· + ···· ·◉ ○· ● · ··· + ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· + ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ + ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● + ○○○●○○ ·◉◉○◉◉● + ● ○○··●● ●◉○◉◉·◉· + ● ◉·●◉·●○○○◉◉● ◉●·● + ·◉○◉◉●○●◉○●◉○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_2.txt b/codex-rs/tui2/frames/dots/frame_2.txt new file mode 100644 index 0000000000..6e7a27fb29 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_2.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●○○○●●○ + ○●●◉●◉●···○··○○●·●●○ + ●·●·○●●·●·· · ●○●◉··◉·● + ○·◉○◉●●●●○ ◉●○·◉·● + ●○ ◉●○·○○○○○ ○●·○·● + ●◉● · ·●○●◉○● ○○·●· + ●·●●· ○◉·◉·○·● ◉ · · + · ○· ●·●··◉● ·●◉◉· + ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● + ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · + ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· + ●○○◉◉● · ●●◉◉●● + ◉○○○·●● ●○○● ○· + ··○◉○○ ●●◉◉○◉◉◉○●·○··● + ·●●●●··○○○○·◉◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_20.txt b/codex-rs/tui2/frames/dots/frame_20.txt new file mode 100644 index 0000000000..d9809e733c --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_20.txt @@ -0,0 +1,17 @@ + + ○○●○··○●○●○○ + ●◉○ ◉●○●○○○··○○●○●● + ●● ○●● ·● · ○ ○●●·●○ + ○· ○●·● ○●●·○◉●○○ + ○· ●·● ○◉ ○··○●○··○ + ◉·●·● ●●●◉· ◉ ●○··○ + ◉·◉●· ●● ◉●●○ ●○·◉ + ·○○◉· ◉◉· ● ·· · + ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ + ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· + ○●○○● ● ○●○··●· ◉○ + ● ○●◉● ○◉●◉·◉ + ○○○●●·○ ●◉●·◉●● + ○●◉◉ ·●○◉◉●◉ · ·●◉●● + ·●●·○ ○○○○○·● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_21.txt b/codex-rs/tui2/frames/dots/frame_21.txt new file mode 100644 index 0000000000..0821f12d75 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_21.txt @@ -0,0 +1,17 @@ + + ○○●○●○○●○●●○○ + ●●●··○○●·○·●◉○·◉●●○ + ◉●○○●●○ · ·○·●●○·●● + ●●·◉ ● ○●◉●●◉●● + ◉●◉●· ●·◉○○ ○○○○● + ◉ ◉· ●●●○◉◉ ○○○● + ·●·· ○ ◉·●◉● ○◉○ + ·○ ○◉ ·● · · + · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · + · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ + ◉ ◉ ·· ●· ●●○●◉ ◉· ● + ●● ◉◉○ ○● ◉ + ●○ ○◉○ ○◉◉●● ●· + ●○○ ● ●●◉··◉◉○● ◉· + ●●●◉●○○○○●○◉●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_22.txt b/codex-rs/tui2/frames/dots/frame_22.txt new file mode 100644 index 0000000000..d673349801 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_22.txt @@ -0,0 +1,17 @@ + + ○●●○●○○●○●●○ + ○●◉●·◉○◉·◉○··◉·●◉●●○ + ●◉●◉·○●●· · ●○●●●○○●● + ◉●·●● ●●···○●○ + ○··◉● ●○◉·○·◉◉◉●· + ○·●◉· ●·◉·◉○◉· ○·○○· + ·●●○◉ ·●·○●◉● ··◉· + ··○·· ·○··○○ ·○· · + ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ + ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ + ○○○○· ●● · ·◉●●○◉◉◉ ●· + ○●○○○● ○●● ·●● + ● ●◉ ●●○ ○◉●● ●○◉ + ●◉●●● ●·◉○●◉◉◉○● ●●● + · ·○◉○○○◉○○◉○●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_23.txt b/codex-rs/tui2/frames/dots/frame_23.txt new file mode 100644 index 0000000000..180ab16784 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_23.txt @@ -0,0 +1,17 @@ + + ○●●○○●●●●○●○○ + ○○·●·○○●○○○●·○·●·●○ + ● ●·●●◉●· ·◉●·○◉··● + ◉◉·●◉◉· ○◉◉○··◉○ + ◉◉··◉● ●◉●●●●◉●○·○ + ●○· ◉· ○●●●●●◉●●··○·● + ··· · ◉◉◉◉ ○·· ·●● + ·◉· · · ·●○○● · ●◉· + ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· + ·○○○· ○ ◉·○ ○·○○●· ○○· + ◉●· · · ●·◉◉○·○◉○◉· + ●··○·◉ ●◉·○○◉· + ○ ·●●◉○○ ○◉ ◉●◉●◉ + ○○○●◉●●○·○○○●● ◉○●●· + · ●○○·○○●○○●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_24.txt b/codex-rs/tui2/frames/dots/frame_24.txt new file mode 100644 index 0000000000..3244b1c6f9 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_24.txt @@ -0,0 +1,17 @@ + + ○·○◉●●●○○●○ + ● ○◉ ·○○·○○●●○●● + ● ●◉○◉● · ◉·◉●·●●● ○ + ●●◉◉●◉·○●○··◉· ○●○·○○● + ● ◉◉○◉● · ◉○○···○·●● + ·●○●○· ●●●◉ ◉·◉ ◉ ○ + · ·· ●◉○●◉ ◉○○·○○·○ + · ○·· ·●○◉○○··○◉◉··· + ·◉ ·◉◉◉◉●●···●●○●●○· · ·· + ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· + ○ ●○·○● · ○●◉●○·○◉●· + ○ ○●●○ · ◉◉◉ + ○◉●○··◉ ○○ ○◉◉○● + ◉◉●●○○ ··◉○○◉·○○◉· + ●◉○●●◉○○○○○●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_25.txt b/codex-rs/tui2/frames/dots/frame_25.txt new file mode 100644 index 0000000000..c04ef18b74 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_25.txt @@ -0,0 +1,17 @@ + + ○●○●◉●●●●○ + ● ●●●·○◉○·●··● + ◉·◉◉●● ◉·●○○○···○ + ·●●· ◉ ○· ○·●●○◉ + ●○○○●◉●○◉◉○· ·◉◉○◉● + ○●···○●●◉○·◉ ◉·●·◉◉·● + ·◉·●· ·○○◉○○◉·○◉○ ·○· + ···◉·◉ ·○○○◉·○○·○··○ + ○●○· ·○○○○○○●··○○·●●·○ + ◉◉ ·◉·●·○··◉●○·○●○◉○· + ○·●◉○·· ●●◉·○·◉·· + ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ + ○·○·○○○●◉◉●○◉○··◉ + ○◉◉○○○●○·●● ●·● + ·● ●○●●○●·● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_26.txt b/codex-rs/tui2/frames/dots/frame_26.txt new file mode 100644 index 0000000000..1ecc43beef --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_26.txt @@ -0,0 +1,17 @@ + + ○◉●●● ●● + ◉●·◉◉○·●◉·● + ◉● ·◉○·○ ○● + ◉● ●···○··○●◉○ + ·○●○·◉· ●○◉◉·○◉ + ○ ·◉◉◉ ●○○ ○○◉ + · ●●○··◉··◉◉ ● + ◉◉ ○···○○○··◉·· + · ◉○●◉●·●○○◉○·· + ◉○·◉●·○○●●○○●· + ·· ◉·● ○●◉·●○·· + ○○··◉○ ◉◉ ··· + ● ●·○ ○·◉·◉● + · ●○○ ●○●●◉ + ○◉◉○●·○○● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_27.txt b/codex-rs/tui2/frames/dots/frame_27.txt new file mode 100644 index 0000000000..83e62da52e --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_27.txt @@ -0,0 +1,17 @@ + + ◉●●●●● + ·◉◉◉·◉○ + ◉●· ●·◉●· + ···○··◉·· + · ··○◉●○ + ·○◉○ ····· + ○ ··○●·· + · ····○ ·· + ·◉ ◉ ·●···· + ●○ ◉ ○·○●·· + ·◉ ○○●●●◉● + ··●●··○◉· + ·○○●○●◉·○ + ○○○·○○◉◉ + ●○ ○○◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_28.txt b/codex-rs/tui2/frames/dots/frame_28.txt new file mode 100644 index 0000000000..6d460c936d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_28.txt @@ -0,0 +1,17 @@ + + ◉●●◉ + ·●○·· + ○ ◉·· + ·◉·●· + · ·· + · ◉ · + ○·· ○· + ·◉ · + ◉○ ○· + ··◉◉·· + · ○· + ·○●○· + ·○●·· + ●○○◉· + · ◉●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_29.txt b/codex-rs/tui2/frames/dots/frame_29.txt new file mode 100644 index 0000000000..d0d6b3c286 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_29.txt @@ -0,0 +1,17 @@ + + ●●●●●● + ●·●◉ ●○ + ○●·· ● · + ····○○●◉ + ○ · · · + ·●··· ○○ + ·○○··· ○ + ··○·· + ···●· · + ○·○·· · + ●··◉·○ ○ + ·◉··· ○· + ······○· + ·○·●◉ ·● + ··● ◉● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_3.txt b/codex-rs/tui2/frames/dots/frame_3.txt new file mode 100644 index 0000000000..062da3ed89 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_3.txt @@ -0,0 +1,17 @@ + + ○◉○◉●●○○○○●○ + ○●●◉○··●· ○○·◉○○·●●○ + ●◉◉●◉·● · · · ●●●◉○·● + ◉●◉○·◉●●○ ·○● ··○ + ●◉●·◉○◉··●○○ ○○○··○ + ◉·○○◉ ◉●·○◉○○ · ○○· + ○● · ●●○·○ ·● ◉●◉·● + ●◉·●· ●◉· ◉○· ·○·○· + ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· + ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· + ●○· · ··●● ●●● ●●●◉◉◉●◉ + ●●· ○● · ◉○●○◉◉ + ○○◉◉●○● ●●◉◉ ●·● + ○○●●○●●○○◉○◉●◉○●◉·○● + ·●●●○◉·○○○○·◉◉○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_30.txt b/codex-rs/tui2/frames/dots/frame_30.txt new file mode 100644 index 0000000000..4bf02ade3d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_30.txt @@ -0,0 +1,17 @@ + + ○◉ ●●●● + ○◉◉··○●●○○ + ●○·●○○·●●○ + ··○·◉·○○·○ ○● + ○●···○·●·● · + ·●·○● ●····◉· + ○◉···○○ ○◉○· ○· + · ●●○·●· ·◉ · + ·○ ○·○·○◉·● · + ··○·○○·· ●● · + ○··◉○○●···●·· + ·●◉◉●◉●○·· · + ○·○··◉●··●·◉ + ●○●●○○◉·●◉● + ○◉◉·○○○◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_31.txt b/codex-rs/tui2/frames/dots/frame_31.txt new file mode 100644 index 0000000000..99385ee51f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_31.txt @@ -0,0 +1,17 @@ + + ○◉◉●●●●○● + ○●●◉●●○ ●○●●● + ●·○◉◉●○○·◉ ·○●○ + ●·◉●○○●◉○●○○·○··○ + ○····●◉●○○● ○·◉○○ + ◉·○··○·● ○◉●●◉◉·● · + ◉·· ·○●·○◉○◉◉●·●·◉· + ○○● ·· ·○ ·○ ··○◉· + ·○●○·●○···◉●···○ · + ···◉◉··○○○○○·○·· ● + ○●○◉●·● ◉●●●·◉·●·○ + ···○○○● ○○● · + ◉·●○○ ● ◉ ·●·◉· + ◉·○·◉○··●· ●·· + ● ○·○●●◉◉◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_32.txt b/codex-rs/tui2/frames/dots/frame_32.txt new file mode 100644 index 0000000000..771e9c9106 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_32.txt @@ -0,0 +1,17 @@ + + ●●●●●◉◉·●○ + ◉●·●●·○··●●··● + ◉○●○○●●○◉◉·●·●○●●● + ●◉◉·○●·◉◉ ◉ ●○○·●● ● + ◉◉·●·●○●●·◉ ●·●·○◉○●○● + ○◉○○●○●··◉··●○ ·●◉ ● + ●· ◉●·●○··●●·●○·◉○◉◉·●○ + ···●○ ○·· ◉●·◉◉○ ○· · + ○··◉○ ●○· ○○·●●●···○· ○· + ● ○··●○●○○○○○○··●·◉·◉○ + ●○●··◉ ·● ●●●○◉◉◉◉◉◉ + ○●·○○○ ●○·◉●· + ●●●···●○ ○◉○·◉ ● + ○◉○ ·●·◉○●·○● ○◉ + ·◉○○○●●◉●○ + ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_33.txt b/codex-rs/tui2/frames/dots/frame_33.txt new file mode 100644 index 0000000000..4d36c1eb6f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_33.txt @@ -0,0 +1,17 @@ + + ○●●○●○●○·◉○ + ○●●··○●○·◉·◉·●·●◉ + ○◉○·●●●●○ · ●◉··● ● + ●○◉·◉○·●○ ●··○○● + ○◉·◉···◉○○○ ·●○○○ + ◉◉● ○○○○·○● ◉○◉○○ + ··● ○●●·○·○ ·●·· + ●·●● ●●●○◉○● ○ ·● + ··· · ●· ◉●●●●◉○○●··●◉○·○· + ○◉·● ◉◉●◉···○○○○○··●○○○··◉ + ○○○● ·◉·◉ ◉ ●●● ··●◉○· + ○●○●● ● ◉◉◉◉○◉ + ●○ ●●●◉● ○●·●◉●○● + ○· ●○●·●◉●○ ◉●○●·● + ○○●●○○○○●●◉◉ + ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_34.txt b/codex-rs/tui2/frames/dots/frame_34.txt new file mode 100644 index 0000000000..4cbd99c143 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_34.txt @@ -0,0 +1,17 @@ + + ○●○●●●●○·●○ + ○●●●◉○◉· ·●●○●●◉●○○ + ○◉◉●·● ◉·●· ·○○○● ●●● + ●◉○·●○◉●○ ●·●●○○ + ◉◉·○◉◉· · ● ○○●○○ + ●◉○··◉ ○○ ·○●○ ○●○·○ + ·●○·○ ●○○●·○○ ·◉·○ + ○·○ ◉ ·●○·◉ · ◉ ○○ + ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· + ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ + ◉○○● ·○·●○ ●●····○ ●◉●○·● + ◉●○◉○○○ ◉◉◉·◉● + ○●··●○◉● ○●○ ○◉●· + ◉○◉· ·●●◉●●○●○●·●●◉● + ·○◉○○○○○○◉○●○○ + ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_35.txt b/codex-rs/tui2/frames/dots/frame_35.txt new file mode 100644 index 0000000000..5ccdf711b5 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_35.txt @@ -0,0 +1,17 @@ + + ○●●◉○●●●○●●○ + ●●○●◉·●●● ·●·◉ ·●○○ + ○◉◉·◉●●·○● · · ●○●◉ ·●● + ●◉◉●◉●◉●○ ●●○○·○ + ◉◉··◉◉○ ○●● ·○●·● + ◉◉◉◉●· ●·○ ◉◉●○ ·○○· + ○ ◉◉ ○○ ●●○◉ ·○○○ + ···◉ ◉○○◉◉● ◉ ◉ + ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● + ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● + ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· + ◉○○◉· ○◉● ●○ + ○◉·○◉●◉● ●◉◉○●●● + ○○ ·●·◉◉··●·◉◉● ○◉◉ + ○·◉○○○◉○○●●● ○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_36.txt b/codex-rs/tui2/frames/dots/frame_36.txt new file mode 100644 index 0000000000..6a26abaea6 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_36.txt @@ -0,0 +1,17 @@ + + ○○●○●●●●○●●○ + ○○ ○●◉◉◉●○●◉●● ●●●○ + ●○●○●●●◉● ·· · ●·●●·●●·● + ○· ●●○○○○ ●●·○ · + ●◉○◉○●·○·○●◉ ○○◉● + ◉ ●◉· ●○ ·◉● ○○● + · ◉ · ○ ○○ ·○·● + ··○· ○○ ○◉◉ ○· · + ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· + ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· + ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ + ●● ○◉◉ ●◉· ◉ + ○◉··●·● ●●● ◉●· + ●●○ ●●○○◉·○●·◉ ◉ ●◉●● + ·●●◉·○●●○●●●●●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_4.txt b/codex-rs/tui2/frames/dots/frame_4.txt new file mode 100644 index 0000000000..b4496013b5 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_4.txt @@ -0,0 +1,17 @@ + + ○◉○◉●○○●○●●○ + ○◉ ●●·◉ ·○○◉●◉· ◉●●○ + ●●○···●● · ·○·○○●·◉○○○ + ◉◉·●· ◉●● ·●●○●○ + ◉·◉○·○·○◉○·● ○ ○·●○ + ◉··○· ●○··◉●○ ● ○○·● + ○·◉· ●●●○● ·● ● ◉· + ·◉○ ●·◉●○·· ··· + ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● + ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · + ··◉●○●○○●○◉ ●●●●●●●●○◉···· + ●·○ ·○· ●○◉◉●●● + ○○●◉○ ●○ ○◉○●●◉·◉ + ···○○●●○◉◉○◉●●○●●○·●· + ●○○·◉·○○○○○◉◉●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_5.txt b/codex-rs/tui2/frames/dots/frame_5.txt new file mode 100644 index 0000000000..0905c495b2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_5.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○○●●○ + ○● ●◉●◉··●·○●◉○· ●● + ●○◉○●·● · ○○·●○ ●●● + ●·◉·◉·◉○● ◉○····○ + ●○◉●◉○○●·○○● · ○·○·○ + ····◉ ○○·○◉◉·○ ●◉·· + ·◉· ● ·○○·○ ·● ●●·●·● + ·◉·○ ●○○·◉○· ●·○· + ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· + ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· + ○●··●·●○○◉●· ●● ●·◉·○◉ + ○○●◉○○ · ◉○◉●●○ + ○◉●○·○●○ ○○●●●●◉● + ○·●·●●●○◉·○○●●○●○·◉● + ○◉●○● ○○○◉○●●●· + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_6.txt b/codex-rs/tui2/frames/dots/frame_6.txt new file mode 100644 index 0000000000..3f96b66761 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_6.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○●●○○ + ●○◉◉●·○●●·●●◉○●●●○ + ●●●···● · ·◉○●●●◉○○ + ◉●·●·●○○● ○●····● + ●○···●○○●●○ ◉○○·○● + ◉◉◉·◉·○●◉·○○·● ◉○○◉· + ○ ●·· ○○·◉○○ ○●·○· + · ··· ○·○·◉·· ·●○·· + ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· + ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ + ◉ · ◉···◉◉ ● ●● ·○○◉· + ● ◉ ◉●· ●◉○◉◉·· + ◉·○○●●● ○●○○◉●●● + · ·●● ● ◉·○○●●●● ◉◉● + ○◉◉●●●○○○·○●●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_7.txt b/codex-rs/tui2/frames/dots/frame_7.txt new file mode 100644 index 0000000000..aa52e1b869 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_7.txt @@ -0,0 +1,17 @@ + + ○◉·◉●●○○●●○ + ●●·●○·●●●○○○◉ ·●● + ● ●···●· ·○·· ●◉○●● + ○○····◉● ○·○●·◉● + ·●·●··○·◉○● ◉ ●··○ + · ◉·· ·●○◉·○ ●◉··· + ·○ · ○○·○·○· ●●○·· + ○ ◉·· ○●◉·●·· ● ··· + ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· + ·●●··○◉··◉●·◉○··○○●○●◉○·○· + ○ ○○◉●·●○◉ ●●●●●● ○◉·· + ●○ ◉○◉○· ● ◉◉◉◉● + ●◉●●○○○○ ●○·●●◉●● + ○○●○·●○◉·○○○●●·●●◉ + ·● ·◉·○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_8.txt b/codex-rs/tui2/frames/dots/frame_8.txt new file mode 100644 index 0000000000..5791ce70e4 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_8.txt @@ -0,0 +1,17 @@ + + ○○●○◉●●○●●○ + ● ●◉···●○○ · ● + ○·○●·◉◉● ● ·◉·●○●○● + ○●○●◉·●●· ● ○○··○ + ● ◉·◉●○·◉○ ◉●○··● + ·●●·○○○○○·○● ○●··· + ·●··· ●○◉○··· ·○·◉·● + ○●··◉ ·●○◉·●●◉ ◉·◉·· + ○ ○··○○ ◉●○··◉●●●●●○··· + ○●◉○○· ●·●··◉·○○○··○● + ◉◉○○·○···◉●○●○● ○● ◉○· + ●● ·○··· ◉●◉◉◉● + ●○○●○○○○ ○◉○○·●◉● + ◉◉●·●● ◉◉●●◉◉○●·· + ··○●●○·○●●◉○· + ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_9.txt b/codex-rs/tui2/frames/dots/frame_9.txt new file mode 100644 index 0000000000..35588ee1ee --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_9.txt @@ -0,0 +1,17 @@ + + ◉○○●◉●●○● + ◉●○◉··● ○ ○◉○○ + ◉ ●··◉●○○○◉ ○○·● + ··◉◉○○ ○○●·○○·●● + ◉● ◉○● ·●● ●◉●●◉●·· + · ··· ○·○◉○○ ·●·●··· + · ·●○·●●·○○● ◉●◉ ··· + · ·◉○●○···○◉○○○· ·· + ●· ◉· ····●··●··· + ·○···○●·○·○····○○··· + ·○◉○○·◉ ●●●●·· ◉○· + ○···○●· ●◉ ○◉·· + ○●○·○○ ◉◉○◉·◉● + ○◉ ○○·●◉●○◉◉●·· + ●◉○ ●○○·◉●◉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_1.txt b/codex-rs/tui2/frames/hash/frame_1.txt new file mode 100644 index 0000000000..45adbbac24 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_1.txt @@ -0,0 +1,17 @@ + + -.-A*##**##- + -*#A**#A#**..*- -█#- + #.*.#**█-- -█*-█.*...# + **-**█##- A*.*.# + *-*A█-.*-** █..**# + .* #- .*A*..# █.*..# + #-█-* █*.*A█.- .A. + ..-.- #AA.*.* █-. + .*.█- *..-*.█..######-## *█ . + -.** -*#- A* .█.---.A###.A#A#. + *--█- -*#.A- --*██* -*█A#*-A + *-# █# - #█A*-. + -*#-*#- -*-#.-#█ + -*#*- *#A****.**#-#.* + -*█*...---#-*#*█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_10.txt b/codex-rs/tui2/frames/hash/frame_10.txt new file mode 100644 index 0000000000..0e9a76d4d8 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_10.txt @@ -0,0 +1,17 @@ + + -#****##- + *█-#*#*.A-*# + --#..A..-.#*## + .--A*.*-.*#██**# + A *..█.- █#A-A.A + .- █.*.AA* --█. + * .*█*....* .A# + █ ..*#A#..---.*.. + █ .* **.*..#*#*.. + . #.*#.-A-*---.*- + # █.....██ *#█*.- + *-█#*#*█ -.██#AA + .█ ....*#A#A.A- + *-**A.#*- AA█ + *# -**-#** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_11.txt b/codex-rs/tui2/frames/hash/frame_11.txt new file mode 100644 index 0000000000..b7e743b218 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_11.txt @@ -0,0 +1,17 @@ + + #****##- + A█ *...**# + A█--..***A*# + .--..**#-*AA + -█.**.#*█*-..# + .#-..#-**.-A.. + ** #......-.** + . ..A..*A .█. + A █.A..*A#.-. + ** -.A*#█A.-.- + █-- ....*A.**. + *--A.-*A***.- + - *.**--.*█ + *# *..#-A*- + *- █*#-#- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_12.txt b/codex-rs/tui2/frames/hash/frame_12.txt new file mode 100644 index 0000000000..0c6c85043f --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_12.txt @@ -0,0 +1,17 @@ + + #***#. + #*--A.#* + █ .A*## + A#...**█. + . █.....A. + .█..*-█-.█ + . .A*..* + . A#*..█ + *# .A..#.. + . A.***- + .. .█***A + # *. -#. + A **.#**. + █-A█#.*.- + * █.*.A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_13.txt b/codex-rs/tui2/frames/hash/frame_13.txt new file mode 100644 index 0000000000..097cd508d7 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_13.txt @@ -0,0 +1,17 @@ + + A***# + .--.. + .--.█ + .**.- + * .. + █A-.. + # .. + # █. + A# -.. + .█ #*. + █-.. .- + .---. + .##.- + *--.* + # .█* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_14.txt b/codex-rs/tui2/frames/hash/frame_14.txt new file mode 100644 index 0000000000..8eca909504 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_14.txt @@ -0,0 +1,17 @@ + + #**** + #A--.* + .-... . + ..A.█-.# + ...* . + .*.. █. + .... . * + ..*.- * + .... █ + █-AA *A + *.██#.#. + .*..---. + A █.***- + .*A*--A + -* █*A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_15.txt b/codex-rs/tui2/frames/hash/frame_15.txt new file mode 100644 index 0000000000..cbf646ab35 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_15.txt @@ -0,0 +1,17 @@ + + ##***.- + -#*..-A*# + AA.***.█*. + .A- AA#.--. + ..*█**..A█-. + *..-..AA. █ + .. ......#. + ..A..#... █-. + ..###*...-.A. + .-.*A.*.*. .. + .A....-. - * + .**.-..*- * + █.*# AAA- *- + .-..**.#*A + *-*----A + -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_16.txt b/codex-rs/tui2/frames/hash/frame_16.txt new file mode 100644 index 0000000000..82698755af --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_16.txt @@ -0,0 +1,17 @@ + + -*#█**#.- + A-..*..*.** + .AA█*A**.. █* + AAA **-*.*..**. + ..*-A*█*..*A.-* + ...*.█ .** . ... + **. A-#....A*█# . + .A* .-..A...█* -. + **...#.A-..█*# A + *█--#****..-. #-. + ...#██A**.*█*...- + ..* .- -AA# A + *.* . A#A-#. + *..█-*.AA#-. + █A.-*A--** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_17.txt b/codex-rs/tui2/frames/hash/frame_17.txt new file mode 100644 index 0000000000..57d02179e7 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_17.txt @@ -0,0 +1,17 @@ + + #*###**.#- + -***...***.#█-# + #**A-**-##█...-█* + A.A-A.█-- █.**...** + .A- . .█A***AA # + -**#A- #AA. A#*A..A + * *A. #AA.- *█..A*. + -█*. AA..A.#..*** + #A*A***#.*-.*-A*... . + .█-*--.A**A..* *#█#* + ***A███*****-.*AA* *█ + *..-* -A..- * + -** **- #█#A--A + # .*#**#--**A.█ + -#*A-##.*-#* + - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_18.txt b/codex-rs/tui2/frames/hash/frame_18.txt new file mode 100644 index 0000000000..ef524a0ed9 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_18.txt @@ -0,0 +1,17 @@ + + -**#**#*.#- + -#█-*###.--#█#**- + -AA.*█**█#█.-█#AA* * + #..*.#-█* █*--A*.*****# + -*AA A. -█#-*A.A.-****- + A*. ##..---A#*#.█-A-A-A. + ..*AA #A*A.█-A█*.*.*# + █*-.. -*█..*- .*.█ + *██ #******#..#.*A*# .*.- + -**.*** ..A--*-...-*.-* # + .A.*-██*****- *--A.A*A--- + ** #*# --*█A*-█ + *-*#--. -*--A*#- + █..**##***-█*-A-#* + █..-A--#.#█*#█ + - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_19.txt b/codex-rs/tui2/frames/hash/frame_19.txt new file mode 100644 index 0000000000..80a9abf012 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_19.txt @@ -0,0 +1,17 @@ + + --**#.#**..- + -#...*A***-*-#.*## + #***A█.#*- -*A#-*** + A*.*#-- -**##-.*# + ..A-A- #.-█A█.* .*# + .*A*█ -.*.A .A █.*. + -*..█ A*-A.##- AAA#. + .... .*█*.█# . ... + .*.*#******....#***.* * █.. + -*A#.**A-*#*#-# -- A*-*#A..- + █*█A#.█████**█ -*-A.AA AA█ + **-*-* -AA-AA█ + █ -*..*# #**.A.*- + ██..*A.#--**A*█ **.█ + █..-A-█-#.-*-- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_2.txt b/codex-rs/tui2/frames/hash/frame_2.txt new file mode 100644 index 0000000000..843df90f28 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_2.txt @@ -0,0 +1,17 @@ + + -.-A*#***##- + -##A**#...*..*-█.*#- + #.*.***.*-- -█***A..*.# + -.A-A*█##- -#*.A.# + #-█A*-.*-**- -#.*.# + █A#█- .**#A*# **.*. + #.█*. -A.*.-.# A█.█. + .█ *- #.*..** .█A.. + *██*. *.---.█..#########.-..* + -*█. A..█ A* .**----..--.#A . + *** * **...█ -█*******█.#*.. + █*-.A# - ##*A#* + .*--.## #*-# -.█ + -.-*--█*#A****.**--..* + -*#*#..----.*A*█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_20.txt b/codex-rs/tui2/frames/hash/frame_20.txt new file mode 100644 index 0000000000..b588df3894 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_20.txt @@ -0,0 +1,17 @@ + + --#*..*#*#-- + #**█*#-#***..--**## + ##█-#*█.*█ -█-█*#*.#- + -.█-*.* -##.*A*** + -. #.* -A -..-**..* + A-#.█ #*█*.█A █*..* + *.*#. #* .#*- #*.A + .--A- ██*A.█# ..█. + .A..*#********-#█*--*** A .* + █.-.-█ A------.* ***█.** #A .. + *#-*████████*█ *#*..#. A* + * -*A# -A*..A + *--*#.- #**-**█ + -#-.█.#-**#A█.█-#A*█ + -*#.- *----.*██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_21.txt b/codex-rs/tui2/frames/hash/frame_21.txt new file mode 100644 index 0000000000..0d1fc7ec26 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_21.txt @@ -0,0 +1,17 @@ + + --#*#**#*##-- + ##*----#.*.#*---*#- + **--#*-█- --.*#--*# + #*.*██ -#**#**# + A#A*- #.A-* **--# + A█A. #**-AA **-# + .█.- - A-#A█ ***█ + -* ** █.# . . + . . ##*****### -.#█.# *A . + . .* .- -#. -█-*.# A█.A + * █. --███ █- █*-#* A- # + █# A*- -*█ A + *- --- -.**█ #- + **- *█*#A..*A**██ -- + *##*#----#-*#*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_22.txt b/codex-rs/tui2/frames/hash/frame_22.txt new file mode 100644 index 0000000000..8fbfdb5713 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_22.txt @@ -0,0 +1,17 @@ + + -##*#**#*##- + -#A*..-A.A*..*.#A*#- + #***.***- -█****#--## + **.**█ ##...-** + *..A█ #-A.*..A*█. + *.*A- #.A.*-A- *.**. + .#**A .#.**A█ ..A. + ..*.- .*..-* .-.█. + ..#█# #.******##-**.- *# -.*█ + -.* *-**# A#█.AA. ***-█.## A** + ***-. █████**█- -A*#-AAA█#- + ***-*# -*#█.#█ + ██#* ##- -.*#█*-A + █A*##█*.**#**A**███#* + █.█.-*----*-.**- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_23.txt b/codex-rs/tui2/frames/hash/frame_23.txt new file mode 100644 index 0000000000..ef2f8adb70 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_23.txt @@ -0,0 +1,17 @@ + + -##-*#*##*#-- + -*.#.*-#-**#.-.*.#- + #█#.█#**- █.A#.**..# + A*.**A- -***..** + AA.-A█ #A##█*A**.* + #*.█A- -***██A*█..*.# + -..█. AAA. -.- █ .█* + .A. . .█.#-*# . *A. + -█* .#.********.-*.-█*- . ... + .**-.█*█ A-* *.-*#. *-. + A█. . -███████ █-A**.-A-A- + #..-.. #A.*-A- + * .#█A-- -.█**A#A + ***#**#*.**-##█--#*- + █. ***.--#--**- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_24.txt b/codex-rs/tui2/frames/hash/frame_24.txt new file mode 100644 index 0000000000..09a7fd520c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_24.txt @@ -0,0 +1,17 @@ + + -.-*##***#- + #█-A█.--.**##-█# + #██**A#█. .-A*.**# * + #█AA#A.*#*-.A- -**.*-█ + # AA*A# -██A*-.--*.#█ + .#*#*- *##A ..* A█* + . .. #A-#A█A-*.*-.- + . *.. .#*A*-..-A.-.. + .. .***A##...##-*#-. . .. + - A *.A##**##..-*#*. .*. + * █*.**██████- *#.**.*A#- + * *#** . █A*A + *.**-.. -*█-AA-* + .-*#*-█..A***.--A- + *A-*#**--**#* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_25.txt b/codex-rs/tui2/frames/hash/frame_25.txt new file mode 100644 index 0000000000..af8bb947f6 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_25.txt @@ -0,0 +1,17 @@ + + -#***####- + # *#*--**.*..# + A.AA##█..#***...- + .#*. A *. █*.#**A + #--*#A█-*A-.█ .AA*A* + *#...-*#.-.A A.#.AA.# + .A.*. .--A*-A.*A-█.*. + ...A..█ .-**A.*-.*..* + -#*.█.--****#..**.#*.* + A-█.A.#.-..**-.***A*. + *.*A*.-██████#A.*.A.- + .-█.*#**-#*A-A#AA.A + ---.-*-**A#-A-..A + *.***-**.** *.* + .#█**##-#.* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_26.txt b/codex-rs/tui2/frames/hash/frame_26.txt new file mode 100644 index 0000000000..7ff85c300a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_26.txt @@ -0,0 +1,17 @@ + + -****█## + A*.*A*.#*.# + A*██.**.*██*# + **█#...-..-#A- + .-#*...█**A*.** + * █.AAA█*-*█**A + . *█*..A..**█# + A- *...--*..A.. + . .***#.#**A*.. + .-.A#.--#****. + .-█A.*█*#A-#*.. + *-..A* AA█..- + * *.* --A.A# + .█***█*-*** + *AA-*.-** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_27.txt b/codex-rs/tui2/frames/hash/frame_27.txt new file mode 100644 index 0000000000..06e988b076 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_27.txt @@ -0,0 +1,17 @@ + + ****## + .*AA.A* + *█- *.A█. + ...-..*.. + . -.--█* + .-.-█-...- + * █..-*.. + . -...*█.. + .. . .#.... + █* .█-.-*.. + .- --**#A█ + ..##..-A. + .**#*#A.- + *--.**AA + █*█ -*A + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_28.txt b/codex-rs/tui2/frames/hash/frame_28.txt new file mode 100644 index 0000000000..0e25818145 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_28.txt @@ -0,0 +1,17 @@ + + A*** + .#-.- + * *.- + .--*. + . .. + . -█. + -.. -. + .-██. + A* -. + ...A.. + . -. + .*#*. + .**.. + *--A. + . .#. + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_29.txt b/codex-rs/tui2/frames/hash/frame_29.txt new file mode 100644 index 0000000000..7f2ddab00a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_29.txt @@ -0,0 +1,17 @@ + + #****# + #.*A ** + -#.. ██. + ....-*#A + * .█. . + .#... -- + .**... - + ..*.. + ...*. - + -.*.. - + *..A.- * + .A... -. + ....-.-. + .*.*A█.█ + ..* .# + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_3.txt b/codex-rs/tui2/frames/hash/frame_3.txt new file mode 100644 index 0000000000..8cce426bb4 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_3.txt @@ -0,0 +1,17 @@ + + -.**##****#- + -##A*..#.█**.*--.*#- + #*A**.*██- -█. **#A-.# + A#A*.*##- -** ..* + █A*.A-A..**- *-*..- + A.**A .#.**** . **. + ** . █**.-█.# .*A.# + #-.#. #-.█A*. .-.*. + -██. *.*#..*-.*##---##-.-█*. + #*A*# .A*A**. .*.--# *.#**-.*.- + █-. . █..##█ █***███**#AAA#A + █*. *# - A-#-AA + *-AA**# ##*A█#.█ + *-##-**-****#A***--#█ + -*#*-A.----.*A-█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_30.txt b/codex-rs/tui2/frames/hash/frame_30.txt new file mode 100644 index 0000000000..24a2165e45 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_30.txt @@ -0,0 +1,17 @@ + + -*█**## + -A*..**█** + ██-.#**.##* + ..*.A.-*.* *# + **..-*.#.# . + .*.-# *...... + *A...*- *A*. *- + █. █#*.#.█.- - + █.-█*.*.*A.# - + █..*.--.. ## - + *..**-*...#.. + .*AA#A**..█ . + *.*..A*..#.A + #-##*-A.#A█ + *-..---*█ + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_31.txt b/codex-rs/tui2/frames/hash/frame_31.txt new file mode 100644 index 0000000000..65f139ab96 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_31.txt @@ -0,0 +1,17 @@ + + -.A*#*#*# + -#**##-█#*█*# + #.*AA#*--.█.-** + #.A#--**-#**.*..* + -....#-**-#█*.A-- + *.--.-.██***#.A.#█. + A.. .-*.*.*A**.*.A. + **# ..█.* .-██..*.. + .*#-.█-...A#...- . + █...AA..---*-.*.. * + -*-A#.# A***-A.█.- + ...***# -** . + *.**- # . .*-A- + A.*.A-.-#.█#.- + ██*.-#*A.** + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_32.txt b/codex-rs/tui2/frames/hash/frame_32.txt new file mode 100644 index 0000000000..6cbec21aec --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_32.txt @@ -0,0 +1,17 @@ + + #####**.#- + **.**.*..**..# + A-**-##-A--*.**█*# + █AA.-#-*- - #-*.## # + AA.#.**█*.* █-#.*A***# + *A**#**.-A..#- .#A * + *.█*#-█*..##.#*-A-AA.█- + ...#- █-..█A█.*--█*.██- + *..-- █-.█--.###...*.█*- + ███--.**█*-----..*.*.-- + #**..A .*██***-*AAA.A + *#.-** #*.A█. + █**...#- -A-.A # + *-*█.#.***--#█-A + .**--##A*-██ + --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_33.txt b/codex-rs/tui2/frames/hash/frame_33.txt new file mode 100644 index 0000000000..a661feb2af --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_33.txt @@ -0,0 +1,17 @@ + + -##*#*#*..- + -#*..***.A.-.*.*. + -A*.#*##*█- █*A..#█# + #*A.A-.#- *..*-* + -..A-..A*-* .#**- + AA# *-**-*# A*A-* + ..# *#*.-.* .*.. + *.## #██*A*# *█.* + ... . #- A*###***#..#A-.*. + -A.██AA#A-..-----..*---... + ***# .A.A A ████*** ..#A-- + *#**#█# AA*A*A + **█*#**# -#.#A**█ + *.█**#.#*#-█*#**.* + █**##----#**-█ + ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_34.txt b/codex-rs/tui2/frames/hash/frame_34.txt new file mode 100644 index 0000000000..3427025326 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_34.txt @@ -0,0 +1,17 @@ + + -#*####*.#- + -#*#A**.█.#*****#*- + -AA#.*█A.*- --**#█**# + #A-.*-A#- *.#*** + AA.*-A. .█ # ****- + #A*.-A **█.*** *#*.* + .*-.- █*-*.-- .*.- + *.*█A .*-.A█. A -* + #█A.█ A*AA█-**######.--█ .-- + ..*#A##-█AA█...-..---#A-..-.* + A-*# .*.*- █**----- #A#*.█ + A#****- A*..A█ + *#-.#**# -#*█-A#- + -*A.█.##*##****-#*A█ + █.***-----****- + --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_35.txt b/codex-rs/tui2/frames/hash/frame_35.txt new file mode 100644 index 0000000000..e0919ec5d0 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_35.txt @@ -0,0 +1,17 @@ + + -##**###*##- + #***..**#█.*.A█.**- + -AA.*#*.-*█- -█***.█.*# + #AA#A#*#- *#-*.- + AA..AA* █**# .**.# + *A*A#- █.-█..*- .**. + * .A ** █*-. .-** + ...A A***A█ A █A + A #- #..A*█A.**-*-*#.. ..** + .#-*█ AA*█*#A.* *#----A█.A█ .█ + A-*#█A*-.A-- -█----*--*AA-#. + █.-*A. -A* #- + **--*#A# #*A-**█ + █--█.*.-A..#.*A*█-A*█ + █-.**--A--##*█- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_36.txt b/codex-rs/tui2/frames/hash/frame_36.txt new file mode 100644 index 0000000000..0355f68b47 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_36.txt @@ -0,0 +1,17 @@ + + --#*####*##- + -*██*#A*A#*#*##█**#- + #*█*##*-*█-- -█*.*#.#*.# + -.█*#---- █*.*█. + #A-**█.*-*#. -*.* + A #A- *- █.A# █*-* + . * - * █** .-.# + ..*. -* -AA *. . + .██. AA #*██###-#####- .*.. + -*.* #.--A.A -.-------.. *A█- + █ *.* #-#A- █**-----█ .#█A + █#█**. #A.█A + *A..#.# ###█A#- + *#-█***-*.-#.*█-█#*#█ + -*#A.-##-####**█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_4.txt b/codex-rs/tui2/frames/hash/frame_4.txt new file mode 100644 index 0000000000..2b4b7c670b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_4.txt @@ -0,0 +1,17 @@ + + -.**#**#*##- + --█#*.A .**A**.█A*#- + #**...#*█- --.*-*.A**- + AA.#.█.## █.***** + A.A-.*.**-.# *█*.** + A..-. █-..A** █ **.# + █*.*- █***#█.# # █*. + ..* #.A*-.. ... + -. .- *.A█-AA#.###----## ..* + .*A . #AAA#-.-**.#.-#AA-*.AA█. + -.*█*#**#*A █*******█--...- + #.*█.-- #-*A**█ + **#A- #- -.-#**.A + ...*-*******##**#*.*- + ***.A.-----*-*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_5.txt b/codex-rs/tui2/frames/hash/frame_5.txt new file mode 100644 index 0000000000..c71575690b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_5.txt @@ -0,0 +1,17 @@ + + -.***#***##- + -#█#A*A..#.*#*-.█## + *-A*#.*█- █-*.** #*# + #.A.A..*# --....- + #*A*A--#.-*# - *.*.- + ....A *-.**A.- #A.. + .A. # -*-.*█.# ██.#.# + .A.-█ #-*.A-. #.-. + .#.-* A-.█#.**-.#.---##-█... + -*.*##A#*█ .█ AA**-#*.#A.A*.- + *#..#.#**A#- █**█████#.*.*A + **#A** - A-A*#- + -A**.*#- -*##*█** + -.*.#*#**.*-##**-.** + --*-*█-----##*- + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_6.txt b/codex-rs/tui2/frames/hash/frame_6.txt new file mode 100644 index 0000000000..799e3a1cf5 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_6.txt @@ -0,0 +1,17 @@ + + -.***#**##-- + #-*A*.**#.##*-#*#- + ###...*█ -█..**#*A*- + A#.*.*-*# *#....# + █*...***#** .**.*# + *-A.A--#*.*-.# .**A. + - #.. -*.A-* -#.-. + . ... -.*.A.. .#*.. + -..-. A█..**A#-.#.--##-.*A. + ..*#A---*# .A**#..#****.#.* + A . A...*A█ █*████*█-**A. + # * A#- #A-AA.- + .-*-**# -#--A*## + -█.*#█# *.--##** A*█ + --**##-----##* + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_7.txt b/codex-rs/tui2/frames/hash/frame_7.txt new file mode 100644 index 0000000000..4a3f9f202f --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_7.txt @@ -0,0 +1,17 @@ + + -..**#**##- + #*-#*.#*#**** -*# + * #...*- --.- *A-*# + **....*# -.**.A# + -█.*..-.A*# . *..* + . *..█ -█**.* #-... + -*█. --.*.*. **-.. + -█A.. *#A.*.- █ ... + A█.* #█-A.*A..**-..****.. + .#█..*A..A█..*..**#*#**.*. + * *-A*.*-A ******██-A.. + █- A-A-- #█-A*A█ + █*##***- #-.#*A*█ + *-█*.***.---#*.*#A + █.*█.A.--#-*** + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_8.txt b/codex-rs/tui2/frames/hash/frame_8.txt new file mode 100644 index 0000000000..4bc5a6f118 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_8.txt @@ -0,0 +1,17 @@ + + --#-*##*##- + #█*A...*** . █# + -.*#.AA* #█.A.**#-# + -***A.##. █ *-..* + █ A.A#*.A* .█*..# + .█#.*---*.-* *#... + .*... █-**.-. .-.A.# + *#..A .#*A.#*. .-A.. + * *..-- A**..A#####*... + **A*-.█#.#..*.*--..*█ + AA**.*...A█-*-*█**█A*. + █#█.*.-- .#AAA█ + █*-****- -A*-.#A* + .A█.#* **#**A*#.- + ..-***.-##A*- + -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_9.txt b/codex-rs/tui2/frames/hash/frame_9.txt new file mode 100644 index 0000000000..db3507db59 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_9.txt @@ -0,0 +1,17 @@ + + .*-*A##*# + A*-A..*█* --*- + A #..A*--*A *-.# + █ ..A**- --#.*-.## + A*█***█.*# *.█#A#.. + . ...█-.**-- .█.*... + - .**.**.**#█.#A -.. + . .A-#*...-A**-. .. + █#.█ A.█....#..#..- + .-...-#.-.-....--... + █ --A**.A█****..█A*. + *...*#- #A -A.- + *#*.** A*-*.A* + --█*-.*A#****.- + █-- ***.*#A█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_1.txt b/codex-rs/tui2/frames/hbars/frame_1.txt new file mode 100644 index 0000000000..ab8be3eb1e --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▇▄▄▇▆▂ + ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ + ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ + ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ + ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ + ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ + ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ + ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ + ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ + ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ + ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ + ▇▃▆ █▆ ▂ ▆█▅▇▂▁ + ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ + ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ + ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_10.txt b/codex-rs/tui2/frames/hbars/frame_10.txt new file mode 100644 index 0000000000..5e565ce40b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▂▇▇▇▇▃▇▇▂ + ▇█▂▇▇▇▃▁▅▂▇▆ + ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ + ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ + ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ + ▁▂ █▁▇▁▅▅▃ ▂▂█▁ + ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ + █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ + █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ + ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ + ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ + ▃▂█▆▃▆▇█ ▂▁██▆▅▅ + ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ + ▄▂▇▇▅▁▇▄▂ ▅▅█ + ▇▆ ▂▇▃▂▆▄▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_11.txt b/codex-rs/tui2/frames/hbars/frame_11.txt new file mode 100644 index 0000000000..5305252a8d --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▇▇▂ + ▅█ ▄▁▁▁▃▃▆ + ▅█▂▂▁▁▄▃▇▅▃▆ + ▁▂▂▁▁▄▄▆▂▄▅▅ + ▂█▅▄▃▁▇▃█▄▂▁▁▆ + ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ + ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ + ▁ ▁▁▅▁▁▃▅ ▁█▁ + ▅ █▁▅▁▁▇▅▇▁▂▁ + ▇▇ ▂▁▅▄▆█▅▁▂▁▃ + █▂▆ ▁▁▁▁▄▅▁▃▃▁ + ▃▂▆▅▁▂▇▅▇▇▄▁▂ + ▂ ▇▁▃▃▃▂▁▄█ + ▃▇ ▇▁▁▆▂▅▇▂ + ▃▂ █▇▇▂▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_12.txt b/codex-rs/tui2/frames/hbars/frame_12.txt new file mode 100644 index 0000000000..cebfe226e1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▅ + ▆▄▂▂▅▁▆▃ + █ ▁▅▃▇▆ + ▅▇▁▁▁▄▄█▁ + ▁ █▁▁▁▁▅▅▁ + ▁█▅▅▇▃█▂▁█ + ▁ ▁▅▃▁▁▃ + ▁ ▅▇▃▁▁█ + ▇▆ ▁▅▁▁▇▁▁ + ▁ ▅▁▇▄▇▂ + ▁▅ ▁█▄▇▇▅ + ▆ ▇▁ ▂▆▁ + ▅ ▇▇▁▆▇▃▁ + █▃▅█▆▁▃▁▂ + ▃ █▁▃▅▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_13.txt b/codex-rs/tui2/frames/hbars/frame_13.txt new file mode 100644 index 0000000000..566cc4ffa3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▅▇▇▇▆ + ▁▂▂▁▁ + ▁▂▂▁█ + ▁▇▇▁▂ + ▇ ▁▁ + █▅▆▁▁ + ▆ ▁▁ + ▇ █▁ + ▅▇ ▂▁▅ + ▁█ ▇▄▁ + █▂▅▅ ▁▂ + ▁▂▂▂▁ + ▁▇▆▁▂ + ▇▂▂▁▄ + ▆ ▅█▄ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_14.txt b/codex-rs/tui2/frames/hbars/frame_14.txt new file mode 100644 index 0000000000..380790e11c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▄ + ▆▅▂▂▅▃ + ▁▂▁▁▅ ▁ + ▁▁▅▁█▃▁▆ + ▁▁▁▃ ▁ + ▁▇▁▁ █▁ + ▁▁▁▁ ▅ ▇ + ▁▁▃▁▂ ▃ + ▁▁▁▁ █ + █▃▅▅ ▄▅ + ▃▁██▆▅▆▁ + ▁▇▁▁▂▂▂▁ + ▅ █▁▄▄▄▂ + ▁▃▅▇▂▂▅ + ▂▇ █▄▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_15.txt b/codex-rs/tui2/frames/hbars/frame_15.txt new file mode 100644 index 0000000000..47d169e98b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▁▂ + ▂▆▄▁▁▃▅▇▆ + ▅▅▁▇▄▃▁█▇▁ + ▁▅▂ ▅▅▆▅▂▂▁ + ▁▁▄█▄▃▁▁▅█▃▁ + ▃▁▁▆▁▁▅▅▁ █ + ▁▁ ▁▁▁▁▁▁▆▁ + ▁▁▅▁▁▇▁▁▁ █▆▁ + ▁▁▇▆▆▇▁▁▁▂▅▅▁ + ▁▂▁▄▅▁▃▁▇▅ ▅▁ + ▁▅▁▁▁▁▂▁ ▂ ▄ + ▁▇▇▅▃▁▁▃▆ ▄ + █▁▃▆ ▅▅▅▂ ▄▂ + ▁▃▁▁▃▃▅▇▃▅ + ▃▃▇▃▂▂▂▅ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_16.txt b/codex-rs/tui2/frames/hbars/frame_16.txt new file mode 100644 index 0000000000..3b1fb1fc5d --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▂▄▇█▇▇▇▁▂ + ▅▃▁▁▇▁▁▃▁▄▃ + ▁▅▅█▃▅▄▃▁▁ █▃ + ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ + ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ + ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ + ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ + ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ + ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ + ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ + ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ + ▁▁▇ ▁▂ ▂▅▅▆ ▅ + ▃▁▇ ▁ ▅▆▅▂▆▁ + ▃▁▁█▂▇▁▅▅▇▂▁ + █▅▅▂▄▅▂▂▄▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_17.txt b/codex-rs/tui2/frames/hbars/frame_17.txt new file mode 100644 index 0000000000..93817e2ead --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▆▄▇▇▇▄▄▁▆▂ + ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ + ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ + ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ + ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ + ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ + ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ + ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ + ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ + ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ + ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ + ▃▁▁▂▇ ▂▅▁▁▂ ▄ + ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ + ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ + ▂▇▄▅▂▆▇▁▇▂▇▇ + ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_18.txt b/codex-rs/tui2/frames/hbars/frame_18.txt new file mode 100644 index 0000000000..03d2c5e94b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▂▄▄▇▄▄▇▄▁▆▂ + ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ + ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ + ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ + ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ + ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ + ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ + █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ + ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ + ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ + ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ + ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ + ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ + █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ + █▁▁▂▅▂▂▆▅▇█▄▇█ + ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_19.txt b/codex-rs/tui2/frames/hbars/frame_19.txt new file mode 100644 index 0000000000..f826776170 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▂▂▄▄▇▁▇▄▄▁▅▂ + ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ + ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ + ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ + ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ + ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ + ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ + ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ + ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ + ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ + █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ + ▃▃▃▇▂▃ ▂▅▅▂▅▅█ + █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ + ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ + █▁▅▂▅▆█▂▆▅▃▇▆▃ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_2.txt b/codex-rs/tui2/frames/hbars/frame_2.txt new file mode 100644 index 0000000000..d4efa4def0 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▄▄▄▇▆▂ + ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ + ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ + ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ + ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ + █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ + ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ + ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ + ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ + ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ + ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ + █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ + ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ + ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ + ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_20.txt b/codex-rs/tui2/frames/hbars/frame_20.txt new file mode 100644 index 0000000000..30c29f51c9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▁▁▄▇▄▆▂▂ + ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ + ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ + ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ + ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ + ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ + ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ + ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ + ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ + █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ + ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ + ▇ ▃▇▅▆ ▂▅▇▅▁▅ + ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ + ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ + ▂▇▇▁▂ ▄▂▂▂▂▁▇██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_21.txt b/codex-rs/tui2/frames/hbars/frame_21.txt new file mode 100644 index 0000000000..b6a6c2c109 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▂▂▆▄▇▄▄▇▄▇▆▂▂ + ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ + ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ + ▆▇▁▄██ ▂▆▄▇▆▄▇▆ + ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ + ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ + ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ + ▂▃ ▃▄ █▁▆ ▁ ▁ + ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ + ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ + ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ + █▇ ▅▄▂ ▂▇█ ▅ + ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ + ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ + ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_22.txt b/codex-rs/tui2/frames/hbars/frame_22.txt new file mode 100644 index 0000000000..38195cd38b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▄▇▄▇▆▂ + ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ + ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ + ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ + ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ + ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ + ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ + ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ + ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ + ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ + ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ + ▃▇▃▂▃▆ ▂▇▆█▁▆█ + ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ + █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ + █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_23.txt b/codex-rs/tui2/frames/hbars/frame_23.txt new file mode 100644 index 0000000000..a81cac3ef2 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▂▆▇▂▄▇▇▇▇▄▇▂▂ + ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ + ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ + ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ + ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ + ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ + ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ + ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ + ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ + ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ + ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ + ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ + ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ + ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ + █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_24.txt b/codex-rs/tui2/frames/hbars/frame_24.txt new file mode 100644 index 0000000000..791f93b591 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▂▁▂▄▇▇▇▄▄▆▂ + ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ + ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ + ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ + ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ + ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ + ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ + ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ + ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ + ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ + ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ + ▃ ▃▆▇▃ ▁ █▅▄▅ + ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ + ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ + ▇▅▂▇▇▄▃▂▂▄▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_25.txt b/codex-rs/tui2/frames/hbars/frame_25.txt new file mode 100644 index 0000000000..565fdb82ea --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▂▇▄▇▄▇▇▇▇▂ + ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ + ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ + ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ + ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ + ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ + ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ + ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ + ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ + ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ + ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ + ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ + ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ + ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ + ▁▆█▇▃▇▆▂▇▁▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_26.txt b/codex-rs/tui2/frames/hbars/frame_26.txt new file mode 100644 index 0000000000..e37d671dc4 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▂▄▇▇▇█▇▆ + ▅▇▁▄▅▃▁▇▄▁▆ + ▅▇██▁▄▃▁▃██▃▆ + ▄▇█▆▁▁▁▂▁▁▂▇▅▃ + ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ + ▃ █▁▅▅▅█▇▂▃█▃▃▅ + ▁ ▇█▄▁▁▅▁▁▄▄█▇ + ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ + ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ + ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ + ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ + ▃▂▁▁▅▃ ▅▅█▁▁▂ + ▇ ▇▁▃ ▃▂▅▁▅▆ + ▁█▇▃▃█▇▂▇▇▄ + ▃▅▅▃▇▁▂▃▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_27.txt b/codex-rs/tui2/frames/hbars/frame_27.txt new file mode 100644 index 0000000000..d3dbefa975 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▄▇▇▇▇▆ + ▁▄▅▅▁▅▃ + ▄█▂ ▇▁▅█▁ + ▁▁▁▃▁▁▄▁▁ + ▁ ▂▁▂▆█▄ + ▁▂▅▂█▂▁▁▁▂ + ▄ █▁▁▂▇▁▁ + ▁ ▂▁▁▁▄█▁▁ + ▁▅ ▅ ▁▇▁▁▁▁ + █▄ ▅█▂▁▂▇▁▁ + ▁▆ ▂▃▇▇▇▅█ + ▁▁▇▇▁▁▂▅▁ + ▁▄▄▇▄▆▅▁▃ + ▃▂▂▁▃▄▅▅ + █▃█ ▃▃▅ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_28.txt b/codex-rs/tui2/frames/hbars/frame_28.txt new file mode 100644 index 0000000000..0ae0f54e0b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▅▇▇▄ + ▁▇▂▁▂ + ▄ ▄▁▂ + ▁▆▂▇▁ + ▁ ▁▁ + ▁ ▆█▁ + ▃▁▁ ▂▁ + ▁▆██▁ + ▅▃ ▂▁ + ▁▁▅▅▁▁ + ▁ ▂▁ + ▁▄▇▄▁ + ▁▄▇▁▁ + ▇▂▂▅▁ + ▁ ▅▇▁ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_29.txt b/codex-rs/tui2/frames/hbars/frame_29.txt new file mode 100644 index 0000000000..d333f278dc --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▆ + ▆▁▇▅ ▇▃ + ▃▆▁▁ ██▁ + ▁▁▁▁▃▃▆▅ + ▃ ▁█▁ ▁ + ▁▆▁▁▁ ▂▂ + ▁▃▃▁▁▁ ▃ + ▁▁▄▁▁ + ▁▁▁▇▁ ▂ + ▂▁▄▁▁ ▂ + ▇▁▁▅▁▃ ▄ + ▁▅▁▁▁ ▂▁ + ▁▁▁▁▂▁▂▁ + ▁▄▁▇▅█▁█ + ▁▁▇ ▅▆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_3.txt b/codex-rs/tui2/frames/hbars/frame_3.txt new file mode 100644 index 0000000000..5d0b07202a --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▇▄▄▄▄▆▂ + ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ + ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ + ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ + █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ + ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ + ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ + ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ + ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ + ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ + █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ + █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ + ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ + ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ + ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_30.txt b/codex-rs/tui2/frames/hbars/frame_30.txt new file mode 100644 index 0000000000..7ceb36d37a --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▂▄█▇▇▇▆ + ▂▅▄▁▁▃▇█▄▃ + ██▃▁▆▃▃▁▇▆▃ + ▁▁▃▁▅▁▂▃▁▃ ▃▆ + ▄▇▁▁▂▃▁▆▁▆ ▁ + ▁▇▁▃▇ ▇▁▁▁▁▅▁ + ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ + █▁ █▇▄▁▆▁█▁▆ ▂ + █▁▂█▃▁▄▁▃▅▁▆ ▂ + █▁▁▃▁▂▂▁▁ ▇▆ ▂ + ▃▁▁▄▃▂▇▁▁▁▇▁▁ + ▁▇▅▅▆▅▇▃▁▁█ ▁ + ▃▁▃▁▁▅▇▁▁▆▁▅ + ▆▃▆▇▄▃▅▁▆▅█ + ▃▆▅▁▃▂▂▄█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_31.txt b/codex-rs/tui2/frames/hbars/frame_31.txt new file mode 100644 index 0000000000..419be30ed9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▂▅▅▇▇▇▇▄▆ + ▂▇▇▄▇▆▂█▇▃█▇▆ + ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ + ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ + ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ + ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ + ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ + ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ + ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ + █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ + ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ + ▁▁▁▃▃▃▆ ▂▄▇ ▁ + ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ + ▅▁▃▁▅▂▁▂▆▁█▆▁▂ + ██▄▁▂▇▇▅▅▄▇ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_32.txt b/codex-rs/tui2/frames/hbars/frame_32.txt new file mode 100644 index 0000000000..1234a419b0 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▄▄▁▆▂ + ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ + ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ + █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ + ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ + ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ + ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ + ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ + ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ + ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ + ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ + ▃▆▁▃▃▃ ▆▃▁▅█▁ + █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ + ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ + ▁▄▄▂▂▆▇▅▇▂██ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_33.txt b/codex-rs/tui2/frames/hbars/frame_33.txt new file mode 100644 index 0000000000..780eb104ef --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▇▄▁▅▂ + ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ + ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ + ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ + ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ + ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ + ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ + ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ + ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ + ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ + ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ + ▃▇▃▇▆█▆ ▅▅▄▅▄▅ + ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ + ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ + █▄▄▇▆▂▂▂▃▇▇▄▆█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_34.txt b/codex-rs/tui2/frames/hbars/frame_34.txt new file mode 100644 index 0000000000..4bf69e69eb --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▂▆▄▇▇▇▇▄▁▆▂ + ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ + ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ + ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ + ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ + ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ + ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ + ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ + ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ + ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ + ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ + ▅▆▃▄▄▃▂ ▅▄▅▁▅█ + ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ + ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ + █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_35.txt b/codex-rs/tui2/frames/hbars/frame_35.txt new file mode 100644 index 0000000000..86dde2ad34 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▄▇▇▇▄▇▆▂ + ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ + ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ + ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ + ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ + ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ + ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ + ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ + ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ + ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ + ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ + █▅▃▃▅▁ ▂▅▇ ▆▃ + ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ + █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ + █▃▁▄▄▂▂▅▂▂▇▇▇█▃ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_36.txt b/codex-rs/tui2/frames/hbars/frame_36.txt new file mode 100644 index 0000000000..bccadcf7b7 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▇▇▇▇▄▇▆▂ + ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ + ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ + ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ + ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ + ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ + ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ + ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ + ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ + ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ + █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ + █▆█▃▄▅ ▆▅▁█▅ + ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ + ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ + ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_4.txt b/codex-rs/tui2/frames/hbars/frame_4.txt new file mode 100644 index 0000000000..5867215a96 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▄▄▇▄▇▆▂ + ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ + ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ + ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ + ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ + ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ + █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ + ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ + ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ + ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ + ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ + ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ + ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ + ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ + ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_5.txt b/codex-rs/tui2/frames/hbars/frame_5.txt new file mode 100644 index 0000000000..d0cd750b8a --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▄▇▆▂ + ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ + ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ + ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ + ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ + ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ + ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ + ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ + ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ + ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ + ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ + ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ + ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ + ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ + ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_6.txt b/codex-rs/tui2/frames/hbars/frame_6.txt new file mode 100644 index 0000000000..2fde73afab --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▇▇▂▂ + ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ + ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ + ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ + █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ + ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ + ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ + ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ + ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ + ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ + ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ + ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ + ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ + ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ + ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_7.txt b/codex-rs/tui2/frames/hbars/frame_7.txt new file mode 100644 index 0000000000..f9b4ed9219 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▂▅▁▄▇▇▄▄▇▆▂ + ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ + ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ + ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ + ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ + ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ + ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ + ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ + ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ + ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ + ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ + █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ + █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ + ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ + █▁▇█▁▅▁▂▂▆▂▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_8.txt b/codex-rs/tui2/frames/hbars/frame_8.txt new file mode 100644 index 0000000000..44c448de8a --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▂▂▇▂▄▇▇▄▇▆▂ + ▆█▇▅▁▁▁▇▄▄ ▁ █▇ + ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ + ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ + █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ + ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ + ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ + ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ + ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ + ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ + ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ + █▆█▁▃▁▂▂ ▅▆▅▅▅█ + █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ + ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ + ▁▁▂▇▇▃▁▂▆▇▅▄▂ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_9.txt b/codex-rs/tui2/frames/hbars/frame_9.txt new file mode 100644 index 0000000000..a18a8a231c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▅▄▃▇▅▇▇▄▆ + ▅▇▂▅▁▁▇█▄ ▂▆▃▂ + ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ + █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ + ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ + ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ + ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ + ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ + █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ + ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ + █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ + ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ + ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ + ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ + █▆▂ ▇▃▃▁▄▇▅█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_1.txt b/codex-rs/tui2/frames/openai/frame_1.txt new file mode 100644 index 0000000000..1019a11c95 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_1.txt @@ -0,0 +1,17 @@ + + aeaenppnnppa + anpeonpepnniina aopa + pioipoooaa aooaoiniiip + noanooppa eoinip + naneoainann oeinnp + io pa ioeniip oeniip + paopo onioeoia iei + iiaia peeinio oai + inioa niianioeippppppapp no i + aino anpa eo ioiaaaeepppiepepi + naaoa anpeea aaoooo aooepnae + oap op a poeoai + anpanpa anapiapo + aopna opennnnenopapio + aoooiiiaaapanpoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_10.txt b/codex-rs/tui2/frames/openai/frame_10.txt new file mode 100644 index 0000000000..942f59e944 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_10.txt @@ -0,0 +1,17 @@ + + apooonppa + ooapopnieaop + aapiieiipipnpp + iaaeninaenpoonnp + e niioia opeaeie + ia oioieen aaoi + n ioooiiiio iep + o iinpepiipaaenii + o in nniniipnpnii + i pinpiaeaoaaaioa + p oiiiiioo nponia + naopnpoo aioopee + io iiiiopepeiea + naooeipna eeo + op aonapno + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_11.txt b/codex-rs/tui2/frames/openai/frame_11.txt new file mode 100644 index 0000000000..ef0aff76e0 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_11.txt @@ -0,0 +1,17 @@ + + pooooppa + eo niiinnp + eoaaiinnoenp + iaaiinnpanee + aoennipnonaiip + ipaiipanniaeii + oo piiiiiiainn + i iieiine ioi + e oieiioepiai + oo aienpoeiaia + oap iiiineinni + napeiaoeoonia + a oinnaaino + np oiipaeoa + na oopapa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_12.txt b/codex-rs/tui2/frames/openai/frame_12.txt new file mode 100644 index 0000000000..8940e05bd6 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_12.txt @@ -0,0 +1,17 @@ + + pooope + pnaaeipn + o ienpp + epiiinnoi + i oiiiieei + ioeeoaoaio + i ieniin + i epniio + op ieiipii + i eionoa + ie ionooe + p oi api + e ooiponi + oaeopinia + n oinee + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_13.txt b/codex-rs/tui2/frames/openai/frame_13.txt new file mode 100644 index 0000000000..c73afab740 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_13.txt @@ -0,0 +1,17 @@ + + eooop + iaaii + iaaio + iooia + o ii + oepii + p ii + p oi + ep aie + io pni + oaee ia + iaaai + ippia + oaain + p eon + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_14.txt b/codex-rs/tui2/frames/openai/frame_14.txt new file mode 100644 index 0000000000..8a273a1666 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_14.txt @@ -0,0 +1,17 @@ + + pooon + peaaen + iaiie i + iieioaip + iiin i + ioii oi + iiii e o + iinia n + iiii o + oaee ne + nioopepi + ioiiaaai + e oinnna + ineoaae + ao one + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_15.txt b/codex-rs/tui2/frames/openai/frame_15.txt new file mode 100644 index 0000000000..5a0e8f1b54 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_15.txt @@ -0,0 +1,17 @@ + + ppoooia + apniiaeop + eeionniooi + iea eepeaai + iinonniieoai + niipiieei o + ii iiiiiipi + iieiipiii opi + iipppoiiiaeei + iaineinioe ei + ieiiiiai a n + iooeaiinp n + oinp eeea na + iaiinnepne + naoaaaae + aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_16.txt b/codex-rs/tui2/frames/openai/frame_16.txt new file mode 100644 index 0000000000..06c519f602 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_16.txt @@ -0,0 +1,17 @@ + + anpooopia + eaiioiininn + ieeonennii on + eee noaniniiooi + iinaeooniioeian + iiioio inn i iei + nni eapiiiieoop i + ien iaiieiiion ai + nniiipieaiioop e + ooaapnnnoieai pai + iiipooeoninoneiia + iio ia aeep e + nio i epeapi + niioaoieepai + oeeaneaano + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_17.txt b/codex-rs/tui2/frames/openai/frame_17.txt new file mode 100644 index 0000000000..0bd4ef6dfc --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_17.txt @@ -0,0 +1,17 @@ + + pnpppnnipa + anooiiionoipoap + poneannappoiiiaon + eieaeioaa oinniiinn + iea i ioennnee p + anopea peei epneiee + o nei peeia ooiieni + poni eeiieipiinno + peoennnpinainaeniii i + ioanaaieoneiin npopn + nnneooooooonaioeen no + niiao aeiia n + ann ona popeaae + p iopnnpaanoeio + apneappioapo + a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_18.txt b/codex-rs/tui2/frames/openai/frame_18.txt new file mode 100644 index 0000000000..de59f344ef --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_18.txt @@ -0,0 +1,17 @@ + + annpnnpnipa + apoanpppiaapopnna + aeeiooonopoeaopeen o + piioepaoo onaaeninnnnnp + anee ei aopaneieiannnna + eni ppieaaaepopiopeaeaei + iinee peoeioaeooininp + onpii anoiina inio + noo pnnnnnnpiipioenp ioia + anniono iieaanaiiianian p + ieinaoooooooa naaeieoeapa + nn pnp aaooeoao + naopaae aoaaenpa + oieooppnnnaooaeapo + oiiaeaapeponpo + a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_19.txt b/codex-rs/tui2/frames/openai/frame_19.txt new file mode 100644 index 0000000000..ade5662359 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_19.txt @@ -0,0 +1,17 @@ + + aannpipnniea + apiiinennnaoapiopp + ponneoipoa aoepaonn + eniopaa annppaiop + iieaea piaoeoin inp + ineoo aioie ee oeoi + aniio eoaeippa eeepi + iiii inoniop i iii + ioinpnnnnnniiiiponoin n oii + anepinoeaopnpap aa enanpeeia + onoepioooooooo anaeiee eeo + nnaoan aeeaeeo + o aniiop pnneeina + ooeioeipaanneoo noio + oieaepoapeaopa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_2.txt b/codex-rs/tui2/frames/openai/frame_2.txt new file mode 100644 index 0000000000..be49360bbf --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_2.txt @@ -0,0 +1,17 @@ + + aeaenpnnnppa + appeonpiiiniinaoiopa + pioinooioaa aoonoeiinip + aieaeooppa ppnieip + paoeoainanna apinip + oepoa ionpenp nnioi + piooi aeiniaip eoioi + io na pioiino ioeei + oooni niaaaioeipppppppppiaiio + anoi eiio eo innaaaaeeaaipe i + nnn o oneeio aoooooooooipoii + onaeep a ppnepo + enaaipp pnap aio + aianaaoopennnnenoaaiio + aopopiiaaaaineoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_20.txt b/codex-rs/tui2/frames/openai/frame_20.txt new file mode 100644 index 0000000000..6eaf358e88 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_20.txt @@ -0,0 +1,17 @@ + + aapniinpnpaa + pnnonpapnnniiaaonpp + ppoapooioo aoaonpoipa + aioaoio appineonn + ai pio ae aiiaoniin + eapio poonioe oniin + ninpi po epoa pnie + iaaea ooneiop iioi + ieiinpnnnnnnnnaponaanno e in + oiaiao eaaaaaain onnoinn pe ii + npanoooooooooo npniipi en + o aoep aeoeie + naaopia pnoanoo + appeoipannpeoioapeoo + aopia naaaaiooo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_21.txt b/codex-rs/tui2/frames/openai/frame_21.txt new file mode 100644 index 0000000000..5f317f375c --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_21.txt @@ -0,0 +1,17 @@ + + aapnpnnpnppaa + ppoaaaapinipnaapopa + noaapoaoa aaiopaaop + poinoo apnopnop + epeoa piean nnaap + eoei pooaee nnap + ioia a eapeo nnno + an nn oip i i + i i ppnnnnnppp aipoip ne i + i in ia api aoaoip eoee + n oe aaooo oa ooapn ea p + op ena aoo e + oa apa aenoo pa + ona ooopeiinenooo pa + oppnpaaaapanpoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_22.txt b/codex-rs/tui2/frames/openai/frame_22.txt new file mode 100644 index 0000000000..74b75b9113 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_22.txt @@ -0,0 +1,17 @@ + + appnpnnpnppa + apeoieaeieniinipeopa + pnoninooa aoonoopaapp + noiooo ppiiiaon + niieo paeinieenoi + nioea pieinaea ninni + ipone ipinoeo iiei + iinia iniian iaioi + iipop pinnnnnnppanoia np aino + ain oannp epoieei nonaoipp enn + nnnai ooooooooa aeopaeeeopa + nonanp aopoipo + oopn ppa aeopooae + oeoppooinnpnnenoooopo + oioianaaapnaenoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_23.txt b/codex-rs/tui2/frames/openai/frame_23.txt new file mode 100644 index 0000000000..35e7fe2210 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_23.txt @@ -0,0 +1,17 @@ + + appanpoppnpaa + anipinapannpiaioipa + popiopnoa oiepinniip + enionea annniinn + eeiaeo peppooeonin + pnioea aoooooeooiinip + aiioi eeee aia o ioo + iei i ioipanp i oei + aon ipinnnnnnnniaoiaona i iii + innaiono ean nianpi nai + eoi i aooooooo oaenniaeaea + piiaie peinaea + n ipoeaa aeonoepe + nnnpnopninnappopapoa + oi onniaapaaooa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_24.txt b/codex-rs/tui2/frames/openai/frame_24.txt new file mode 100644 index 0000000000..a74ea1f0bb --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_24.txt @@ -0,0 +1,17 @@ + + aianpponnpa + poaeoiaainnppaop + poonnepoi eaeoioop n + poeepeinpnaiea aoninao + p eenep aooenaiaanipo + ipnpna oppe ein eon + i ii peapeoeaninaia + i nii ipnenaiiaeeaii + ie innneppiiippaopai i ii + a e oieppnnppieanpoi ioi + n oninoooooooa npeoninepa + n npon i oene + neonaie anoaeeao + epopnaoiiennniaaea + oeaopnnaannpo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_25.txt b/codex-rs/tui2/frames/openai/frame_25.txt new file mode 100644 index 0000000000..c2c5b30b29 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_25.txt @@ -0,0 +1,17 @@ + + apnonppppa + p opoaannioiip + eieeppoeipnnniiia + ipoi e ni onipone + paanpeoaneaio ieeneo + npiiiaopeaie eipieeip + ieioi iaaenaeineaoini + iiieieo ianneinainiin + apnioiaannnnpiinnipoin + epoieipiaiinoainoneni + nioeniaoooooopeinieia + iaoinpnnppoeaepeeie + aaaianaonepaeaiie + nennnaonioo oio + ipoonppapio + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_26.txt b/codex-rs/tui2/frames/openai/frame_26.txt new file mode 100644 index 0000000000..09a947d35d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_26.txt @@ -0,0 +1,17 @@ + + anoooopp + eoinenipnip + eoooinninoonp + noopiiiaiiapea + iapnieiooneninn + n oieeeooanonne + i ooniieiinnop + ep niiiaaniieii + i enonpipnnenii + eaiepiaaponnoi + iaoeioonpeapnii + naiien eeoiia + o oin aaeiep + ioonnooaoon + neeaoiano + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_27.txt b/codex-rs/tui2/frames/openai/frame_27.txt new file mode 100644 index 0000000000..b3fef11ac8 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_27.txt @@ -0,0 +1,17 @@ + + nooopp + ineeien + noa oieoi + iiiaiinii + i aiapon + iaeaoaiiia + n oiiaoii + i aiiinoii + ie e ipiiii + on eoaiaoii + ip aaoopeo + iippiiaei + innpnpeia + naainnee + ono ane + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_28.txt b/codex-rs/tui2/frames/openai/frame_28.txt new file mode 100644 index 0000000000..11fdcec520 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_28.txt @@ -0,0 +1,17 @@ + + eoon + ipaia + n nia + ipaoi + i ii + i poi + aii ai + ipooi + en ai + iieeii + i ai + inpni + inoii + oaaei + i epi + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_29.txt b/codex-rs/tui2/frames/openai/frame_29.txt new file mode 100644 index 0000000000..2dc6c66753 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_29.txt @@ -0,0 +1,17 @@ + + poooop + pioe on + apii ooi + iiiianpe + n ioi i + ipiii aa + inniii a + iinii + iiioi a + ainii a + oiieia n + ieiii ai + iiiiaiai + inioeoio + iio ep + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_3.txt b/codex-rs/tui2/frames/openai/frame_3.txt new file mode 100644 index 0000000000..9026d59a43 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_3.txt @@ -0,0 +1,17 @@ + + aennppnnnnpa + appeniipionninaaiopa + pneonioooa aoi oopeaip + epeninppa ano iin + oeoieaeiiona naniia + einne epinnnn i nni + no i ooniaoip eoeip + ppipi ppioeni iaini + pooi niopiiopinppaaappaiaoni + pnenp eeoenni ioiaap nepnnainia + oai i oiippo ooooooooopeeepe + ooi np a eapaee + naeeonp ppneopio + nappaooannnnpenonaapo + aopoaeiaaaaineao + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_30.txt b/codex-rs/tui2/frames/openai/frame_30.txt new file mode 100644 index 0000000000..73b4906d0e --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_30.txt @@ -0,0 +1,17 @@ + + anooopp + aeniinoonn + ooaipnnippn + iinieianin np + noiianipip i + ioiap oiiiiei + neiiina neni na + oi opnipioip a + oiaoninineip a + oiiniaaii pp a + niinnaoiiipii + ioeepeoniio i + niniieoiipie + pappnaeipeo + npeiaaano + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_31.txt b/codex-rs/tui2/frames/openai/frame_31.txt new file mode 100644 index 0000000000..cc71fce920 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_31.txt @@ -0,0 +1,17 @@ + + aeeopopnp + aponppaopnoop + pineepnaaeoiaon + piepaaonapnniniin + aiiiipponaponieaa + niaaiaioonnopeeipoi + eii iaoinenenoioiei + nnp iioin iaooiinei + inpaioaiiiepiiia i + oiiieeiiaaanainii o + aoaepip eoooaeioia + iiinnnp ano i + niona p e ioaea + einieaiapiopia + ooniapoeeno + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_32.txt b/codex-rs/tui2/frames/openai/frame_32.txt new file mode 100644 index 0000000000..c0d6573da7 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_32.txt @@ -0,0 +1,17 @@ + + pppppnnipa + noiooiniiooiip + eaonappaepaoionoop + oeeiapanp p panipp p + eeipionooin oapinenonp + nennpnoiaeiipa ipe o + oionpaoniippipnaeaeeioa + iiipa oaiioeoinpaoniooa + niipa oaioaaipppiiiniona + oooaaiononaaaaaiioinipa + pnoiie iooooooaneeeee + npiann pnieoi + oooiiipa aeaie p + npnoipinnoaapoae + innaappeoaoo + aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_33.txt b/codex-rs/tui2/frames/openai/frame_33.txt new file mode 100644 index 0000000000..56ef96d36a --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_33.txt @@ -0,0 +1,17 @@ + + appnpnpniea + apoiinonieipioioe + aenipoppnoa ooeiipop + pneieaipa oiinao + aeieaiienan ipnna + eep nannanp enean + iip npoiain ioii + oipp poonenp noio + iii i pa eopppnnnpiipeaini + aeiooeepeaiiaaaaaiioaaaiie + nnnp ieie e ooooooo iipeaa + npnopop eenene + onooponp apipeono + nioonpipnpaonpnoio + onnppaaaaponpo + aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_34.txt b/codex-rs/tui2/frames/openai/frame_34.txt new file mode 100644 index 0000000000..b6e87c62f1 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_34.txt @@ -0,0 +1,17 @@ + + apnppppnipa + apopennioiponoonpna + aeepiooeioa aannpooop + peaioaepa oiponn + eeinpei io p nnona + peniae nnoinon npnin + ioaia onaoiaa inia + ninoe ioaieoi e an + poeeo eneeoannppppppiaao iaa + iinpeppaoeeoiiiaieaaapeaiiain + eanp inioa oooaaaaa pepnio + epnnnna eneieo + npaipnnp apnoaepa + pneioippnppnonoapoeo + oinnnaaaaannona + aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_35.txt b/codex-rs/tui2/frames/openai/frame_35.txt new file mode 100644 index 0000000000..899d6766b7 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_35.txt @@ -0,0 +1,17 @@ + + appnnpppnppa + ponoeioopoioieoiona + aeeinpoiaooa aoonoeoiop + peepepnpa opania + eeiieen onop inoip + nenepa oiaoeeoa inni + n ee nn ooae iann + iiie ennneo e oe + e pa piienoeinnananpii iino + ipano eenoopein npaaaaeoieo io + eanpoenaieaa aoaaaaoaaoeeapi + oeanei aeo pa + nnaanpep pneaooo + oaaoioipeiipineooaeno + oainnaaeaappooa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_36.txt b/codex-rs/tui2/frames/openai/frame_36.txt new file mode 100644 index 0000000000..9a23d2ddd6 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_36.txt @@ -0,0 +1,17 @@ + + aapnppppnppa + anoonpenepnpnppooopa + pnonppopooaa aooiopipoip + aioopaaaa ooinoi + peannoinanpe aneo + e pea oa oiep onao + i n a n onn iaip + iini an aee ni i + iooi ee pooopppapppppa inii + aoin piaaeie aiaaaaaaaii neoa + o oin papea oooaaaaao ipoe + oponne peioe + neiipip pppoepa + opaooonaniapinopopnpo + aopeiappappppooo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_4.txt b/codex-rs/tui2/frames/openai/frame_4.txt new file mode 100644 index 0000000000..0c76cc5ce8 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_4.txt @@ -0,0 +1,17 @@ + + aennpnnpnppa + apopoie inneonioeopa + poniiipooa aainaoienna + eeipioepp oioonon + eieaininnaip nonion + eiiai oaiieon o nnip + onina ooonpoip p oni + ien pieoaii iii + ai ia nieoaeepipppaaaapp iio + ine i peeepaiannipeapeeanieeoi + ainonpnnpne oooooooooapiiia + pinoiaa paneooo + nnpea pa aeaponie + iiinaoonnnnnppnopnioa + onnieiaaaaanpoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_5.txt b/codex-rs/tui2/frames/openai/frame_5.txt new file mode 100644 index 0000000000..2b06cade09 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_5.txt @@ -0,0 +1,17 @@ + + aennnpnnnppa + apopeoeiipinpnaiopp + oaenpiooa oanion pop + pieieienp paiiiia + pneoeaapianp a ninia + iiiie nainneia peii + iei p anainoip ooipip + ieiao panieai piai + ipiao eaiopionaipiaaappaoiei + aninppepoo io eennapnepeieoia + npiipipnnepa oooooooopinine + nnpenn a eaeopa + aeoninpa anppoono + aioipopnninappnoaino + apoaooaaapappoa + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_6.txt b/codex-rs/tui2/frames/openai/frame_6.txt new file mode 100644 index 0000000000..2ca8bb0bc7 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_6.txt @@ -0,0 +1,17 @@ + + aennnpnnppaa + paneoinopippnapopa + pppiiioo aoienopoena + epioioanp npiiiip + oniiionnpon enninp + npeieaapninaip ennei + a pii aniean apiai + i iii ainieii ipnii + aeipi eoiionepaipiaappaenei + iinpeaaanp ienopiipnnnnipin + e i eiiineo ooooooooannei + p n epa peaeeia + eanaoop apaaeopp + aoiopop niaappoo eno + apnoppaaaaappo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_7.txt b/codex-rs/tui2/frames/openai/frame_7.txt new file mode 100644 index 0000000000..f66ddaf5a6 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_7.txt @@ -0,0 +1,17 @@ + + aeinopnnppa + poapnipopnnnn aop + o piiioa aaia oeaop + nniiiinp ainoiep + aoioiiaienp e oiin + i niio aonnin ppiii + anoi aainini ooaii + aoeii npeioia o iii + eoio poaeineiinnaiinnonii + ipoiineiieoieniinnpnpnnini + n naeoioae ooooooooaeii + oa eaeaa popeneo + onppnnna paipoeoo + naonionniaaapoiope + oiooieiaapanoo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_8.txt b/codex-rs/tui2/frames/openai/frame_8.txt new file mode 100644 index 0000000000..e54163d2c8 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_8.txt @@ -0,0 +1,17 @@ + + aapanppnppa + pooeiiionn i op + ainpieeo poieionpap + aonoeippi o naiin + o eiepnien eoniip + iopinaaaniao npiii + ioiii oanniai iaieip + npiie ipneipoe eaeii + n niiaa eoniiepppppniii + noenaiopipiininaaiino + eenniniiieoaoaoonooeni + opoiniaa epeeeo + onaonnna aenaipeo + eeoipo nnponenpia + iiaooniappena + aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_9.txt b/codex-rs/tui2/frames/openai/frame_9.txt new file mode 100644 index 0000000000..a339de1118 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_9.txt @@ -0,0 +1,17 @@ + + enaoeppnp + eoaeiioon apna + e piieoaane naip + o iienna aapinaipp + eoonnooiop oeopepii + i iiioainnaa ioioiii + a ioniooinnpoepe aii + i ieapniiiaennai ii + opio eioiiiipiipiia + iaiiiapiaiaiiiiaaiii + o aaennieoooooiioeni + niiinpa pe aeia + npninn enanieo + aponaioepnnnoia + opa onninpeo + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_1.txt b/codex-rs/tui2/frames/shapes/frame_1.txt new file mode 100644 index 0000000000..244e2470b4 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_1.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□□●●□▲◆ + ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ + ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ + ●□◆○□■▲▲◆ △□◇●◇▲ + ○○●△■○◇○◆○○ ■△◇○○▲ + ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ + □○■▲□ ■○◇□△■◇◆ ◇△◇ + ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ + ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ + ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ + ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ + □○▲ ■▲ ◆ ▲■△□◆◇ + ○○▲◆○□◆ ◆●◆□◇◆□■ + ○□▲○◆ □□△●●●●△●□□◆▲◇□ + ◆□■□◇◇◇◆◆◆▲◆●□□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_10.txt b/codex-rs/tui2/frames/shapes/frame_10.txt new file mode 100644 index 0000000000..f306dffc08 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_10.txt @@ -0,0 +1,17 @@ + + ◆□□□□○□□◆ + □■◆□□□○◇△◆□▲ + ○◆▲◇◇△◇◇▲◇□○▲▲ + ◇◆◆△○◇●◆△○▲■■○○▲ + △ ●◇◇■◇○ ■▲△◆△◇△ + ◇◆ ■◇□◇△△○ ◆◆■◇ + ○ ◇□■□◇◇◇◇□ ◇△▲ + ■ ◇◇○□△□◇◇▲◆◆△○◇◇ + ■ ◇○ ○○◇●◇◇□○□●◇◇ + ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ + ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ + ○◆■▲○▲□■ ◆◇■■▲△△ + ◇■ ◇◇◇◇□▲△▲△◇△◆ + ●◆□□△◇□●◆ △△■ + □▲ ◆□○◆▲●□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_11.txt b/codex-rs/tui2/frames/shapes/frame_11.txt new file mode 100644 index 0000000000..dcf944902b --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_11.txt @@ -0,0 +1,17 @@ + + ▲□□□□□□◆ + △■ ●◇◇◇○○▲ + △■◆◆◇◇●○□△○▲ + ◇◆◆◇◇●●▲◆●△△ + ◆■△●○◇□○■●◆◇◇▲ + ◇□◆◇◇□◆●●◇◆△◇◇ + □□ ▲◇◇◇◇◇◇◆◇●○ + ◇ ◇◇△◇◇○△ ◇■◇ + △ ■◇△◇◇□△□◇◆◇ + □□ ◆◇△●▲■△◇◆◇○ + ■◆▲ ◇◇◇◇●△◇○○◇ + ○◆▲△◇◆□△□□●◇◆ + ◆ □◇○○○◆◇●■ + ○□ □◇◇▲◆△□◆ + ○◆ ■□□◆□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_12.txt b/codex-rs/tui2/frames/shapes/frame_12.txt new file mode 100644 index 0000000000..d8d1fbf334 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_12.txt @@ -0,0 +1,17 @@ + + □□□□□△ + ▲●◆◆△◇▲○ + ■ ◇△○□▲ + △□◇◇◇●●■◇ + ◇ ■◇◇◇◇△△◇ + ◇■△△□○■◆◇■ + ◇ ◇△○◇◇○ + ◇ △□○◇◇■ + □▲ ◇△◇◇□◇◇ + ◇ △◇□●□◆ + ◇△ ◇■●□□△ + ▲ □◇ ◆▲◇ + △ □□◇▲□○◇ + ■○△■▲◇○◇◆ + ○ ■◇○△△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_13.txt b/codex-rs/tui2/frames/shapes/frame_13.txt new file mode 100644 index 0000000000..1387fc9b91 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_13.txt @@ -0,0 +1,17 @@ + + △□□□▲ + ◇◆◆◇◇ + ◇◆◆◇■ + ◇□□◇◆ + □ ◇◇ + ■△▲◇◇ + ▲ ◇◇ + □ ■◇ + △□ ◆◇△ + ◇■ □●◇ + ■◆△△ ◇◆ + ◇◆◆◆◇ + ◇□▲◇◆ + □◆◆◇● + ▲ △■● + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_14.txt b/codex-rs/tui2/frames/shapes/frame_14.txt new file mode 100644 index 0000000000..70a5070ba9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_14.txt @@ -0,0 +1,17 @@ + + □□□□● + ▲△◆◆△○ + ◇◆◇◇△ ◇ + ◇◇△◇■○◇▲ + ◇◇◇○ ◇ + ◇□◇◇ ■◇ + ◇◇◇◇ △ □ + ◇◇○◇◆ ○ + ◇◇◇◇ ■ + ■○△△ ●△ + ○◇■■▲△▲◇ + ◇□◇◇◆◆◆◇ + △ ■◇●●●◆ + ◇○△□◆◆△ + ◆□ ■●△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_15.txt b/codex-rs/tui2/frames/shapes/frame_15.txt new file mode 100644 index 0000000000..584e0e043a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_15.txt @@ -0,0 +1,17 @@ + + □□□□□◇◆ + ◆▲●◇◇○△□▲ + △△◇□●○◇■□◇ + ◇△◆ △△▲△◆◆◇ + ◇◇●■●○◇◇△■○◇ + ○◇◇▲◇◇△△◇ ■ + ◇◇ ◇◇◇◇◇◇▲◇ + ◇◇△◇◇□◇◇◇ ■▲◇ + ◇◇□▲▲□◇◇◇◆△△◇ + ◇◆◇●△◇○◇□△ △◇ + ◇△◇◇◇◇◆◇ ◆ ● + ◇□□△○◇◇○▲ ● + ■◇○▲ △△△◆ ●◆ + ◇○◇◇○○△□○△ + ○○□○◆◆◆△ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_16.txt b/codex-rs/tui2/frames/shapes/frame_16.txt new file mode 100644 index 0000000000..af6c836855 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_16.txt @@ -0,0 +1,17 @@ + + ◆●□■□□□◇◆ + △○◇◇□◇◇○◇●○ + ◇△△■○△●○◇◇ ■○ + △△△ ●□◆○◇○◇◇□□◇ + ◇◇●◆△□■●◇◇□△◇◆○ + ◇◇◇□◇■ ◇○● ◇ ◇△◇ + ○○◇ △◆▲◇◇◇◇△□■▲ ◇ + ◇△● ◇◆◇◇△◇◇◇■● ◆◇ + ○○◇◇◇□◇△○◇◇■□□ △ + □■◆◆▲●●○□◇△◆◇ ▲◆◇ + ◇◇◇□■■△□○◇●■●△◇◇◆ + ◇◇□ ◇◆ ◆△△▲ △ + ○◇□ ◇ △▲△◆▲◇ + ○◇◇■◆□◇△△□◆◇ + ■△△◆●△◆◆●□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_17.txt b/codex-rs/tui2/frames/shapes/frame_17.txt new file mode 100644 index 0000000000..4a158cf609 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_17.txt @@ -0,0 +1,17 @@ + + ▲●□□□●●◇▲◆ + ◆●□□◇◇◇□●□◇▲■○▲ + ▲□○△◆●●◆□▲■◇◇◇◆■○ + △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ + ◇△◆ ◇ ◇■△○●○△△ ▲ + ◆●□▲△◆ ▲△△◇ △▲●△◇△△ + □ ●△◇ ▲△△◇◆ □■◇◇△●◇ + ▲■●◇ △△◇◇△◇▲◇◇●●□ + ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ + ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ + ○○○△■■■□□□□○◆◇□△△○ ○■ + ○◇◇◆□ ◆△◇◇◆ ● + ◆○○ □○◆ ▲■▲△○◆△ + ▲ ◇□□●○□◆◆●□△◇■ + ◆□●△◆▲□◇□◆□□ + ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_18.txt b/codex-rs/tui2/frames/shapes/frame_18.txt new file mode 100644 index 0000000000..16bf8c1b58 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_18.txt @@ -0,0 +1,17 @@ + + ◆●●□●●□●◇▲◆ + ◆□■◆●▲□□◇◆◆▲■□●●◆ + ◆△△◇□■□●■▲■△◆■□△△○ □ + ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ + ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ + △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ + ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ + ■○▲◇◇ ○○■◇◇●○ ◇●◇■ + ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ + ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ + ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ + ○○ ▲○▲ ◆◆□■△□◆■ + ○○□□○○△ ◆□○◆△○▲◆ + ■◇△□□□□●●●○■□◆△◆▲□ + ■◇◇◆△◆◆▲△□■●□■ + ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_19.txt b/codex-rs/tui2/frames/shapes/frame_19.txt new file mode 100644 index 0000000000..e1bc51ae1b --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_19.txt @@ -0,0 +1,17 @@ + + ◆◆●●□◇□●●◇△◆ + ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ + ▲□●●△■◇□□◆ ◆□△▲◆□●○ + △●◇□▲○◆ ◆●●□▲○◇□▲ + ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ + ◇○△□■ ◆◇□◇△ △△ ■△□◇ + ○●◇◇■ △□○△◇□▲◆ △△△□◇ + ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ + ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ + ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ + ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ + ○○○□◆○ ◆△△◆△△■ + ■ ◆○◇◇□▲ ▲●●△△◇●◆ + ■■△◇□△◇□◆◆●●△□■ ●□◇■ + ■◇△◆△▲■◆▲△○□▲○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_2.txt b/codex-rs/tui2/frames/shapes/frame_2.txt new file mode 100644 index 0000000000..af71459f5e --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_2.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□●●●□▲◆ + ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ + ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ + ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ + ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ + ■△□■◆ ◇□○□△○□ ○○◇□◇ + ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ + ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ + □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ + ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ + ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ + ■○◆△△▲ ◆ ▲□●△▲□ + △○◆◆◇□▲ ▲●◆□ ◆◇■ + ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ + ◆□□□□◇◇◆◆◆◆◇●△□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_20.txt b/codex-rs/tui2/frames/shapes/frame_20.txt new file mode 100644 index 0000000000..c5eb01382d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_20.txt @@ -0,0 +1,17 @@ + + ◆◆□●◇◇●□●▲◆◆ + ▲●●■●▲◆▲●●●◇◇◆○□○□▲ + ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ + ◆◇■◆□◇□ ◆▲□◇○△□○○ + ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ + △◆▲◇■ □□■●◇■△ ■○◇◇○ + ●◇●□◇ ▲□ △□□○ ▲○◇△ + ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ + ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ + ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ + ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ + □ ○□△▲ ◆△□△◇△ + ○◆○□▲◇◆ ▲●□◆●□■ + ○▲▲△■◇□○●●▲△■◇■◆▲△□■ + ◆□□◇◆ ●◆◆◆◆◇□■■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_21.txt b/codex-rs/tui2/frames/shapes/frame_21.txt new file mode 100644 index 0000000000..944b99f058 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_21.txt @@ -0,0 +1,17 @@ + + ◆◆▲●□●●□●□▲◆◆ + ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ + ●□○◆□□○■◆ ◆○◇□□◆◆□▲ + ▲□◇●■■ ◆▲●□▲●□▲ + △▲△□◆ ▲◇△◆○ ○○◆○▲ + △■△◇ ▲□□◆△△ ○○○▲ + ◇■◇◆ ◆ △◆▲△■ ○●○■ + ◆○ ○● ■◇▲ ◇ ◇ + ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ + ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ + ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ + ■□ △●◆ ◆□■ △ + □◆ ○▲◆ ◆△●□■ ▲◆ + □●◆ □■□□△◇◇●△●□■■ ▲◆ + □□□●□◆◆◆◆▲◆●□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_22.txt b/codex-rs/tui2/frames/shapes/frame_22.txt new file mode 100644 index 0000000000..60ea930d46 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_22.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●●□●□▲◆ + ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ + ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ + ●□◇□□■ ▲▲◇◇◇◆□○ + ○◇◇△■ ▲◆△◇●◇△△●■◇ + ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ + ◇□□○△ ◇□◇○□△■ ◇◇△◇ + ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ + ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ + ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● + ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ + ○□○◆○▲ ◆□▲■◇▲■ + ■■□● □□◆ ◆△□▲■□◆△ + ■△□□▲■□◇●●□●●△●□■■■▲□ + ■◇■◇◆●◆◆◆▲●◆△●□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_23.txt b/codex-rs/tui2/frames/shapes/frame_23.txt new file mode 100644 index 0000000000..5d340640bf --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_23.txt @@ -0,0 +1,17 @@ + + ◆▲□◆●□□□□●□◆◆ + ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ + ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ + △●◇□●△◆ ◆●●○◇◇●○ + △△◇◆△■ ▲△▲▲■□△□○◇○ + ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ + ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ + ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ + ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ + ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ + △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ + ▲◇◇◆◇△ ▲△◇○○△◆ + ○ ◇▲■△◆◆ ◆△■●□△□△ + ○●○□●□□○◇●●◆□□■▲◆□□◆ + ■◇ □●○◇◆◆□◆◆□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_24.txt b/codex-rs/tui2/frames/shapes/frame_24.txt new file mode 100644 index 0000000000..558224147d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_24.txt @@ -0,0 +1,17 @@ + + ◆◇◆●□□□●●▲◆ + ▲■◆△■◇◆◆◇●●□▲◆■□ + ▲■■●○△□■◇ △◆△□◇□□▲ ○ + ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ + ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ + ◇▲○□○◆ □▲▲△ △◇● △■○ + ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ + ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ + ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ + ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ + ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ + ○ ○▲□○ ◇ ■△●△ + ○△□○◆◇△ ◆●■◆△△◆□ + △▲□▲○○■◇◇△●●●◇○◆△◆ + □△◆□□●○◆◆●●□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_25.txt b/codex-rs/tui2/frames/shapes/frame_25.txt new file mode 100644 index 0000000000..38d3250764 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_25.txt @@ -0,0 +1,17 @@ + + ◆□●□●□□□□◆ + ▲ □▲□◆◆●○◇□◇◇▲ + △◇△△▲▲■△◇□○●○◇◇◇◆ + ◇□□◇ △ ●◇ ■○◇▲□○△ + ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ + ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ + ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ + ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ + ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ + △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ + ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ + ◇◆■◇●□●○▲▲□△◆△▲△△◇△ + ○◆○◇◆○◆□●△▲○△◆◇◇△ + ○△●○○◆□●◇□□ □◇□ + ◇▲■□○□▲◆□◇□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_26.txt b/codex-rs/tui2/frames/shapes/frame_26.txt new file mode 100644 index 0000000000..4aac44389a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_26.txt @@ -0,0 +1,17 @@ + + ◆●□□□■□▲ + △□◇●△○◇□●◇▲ + △□■■◇●○◇○■■○▲ + ●□■▲◇◇◇◆◇◇◆□△○ + ◇◆▲○◇△◇■□○△●◇○● + ○ ■◇△△△■□◆○■○○△ + ◇ □■●◇◇△◇◇●●■□ + △▲ ○◇◇◇○○●◇◇△◇◇ + ◇ △○□●□◇□●●△○◇◇ + △◆◇△▲◇◆◆▲□○○□◇ + ◇◆■△◇□■○▲△◆□○◇◇ + ○◆◇◇△○ △△■◇◇◆ + □ □◇○ ○◆△◇△▲ + ◇■□○○■□◆□□● + ○△△○□◇◆○□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_27.txt b/codex-rs/tui2/frames/shapes/frame_27.txt new file mode 100644 index 0000000000..9896590f79 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_27.txt @@ -0,0 +1,17 @@ + + ●□□□□▲ + ◇●△△◇△○ + ●■◆ □◇△■◇ + ◇◇◇○◇◇●◇◇ + ◇ ◆◇◆▲■● + ◇◆△◆■◆◇◇◇◆ + ● ■◇◇◆□◇◇ + ◇ ◆◇◇◇●■◇◇ + ◇△ △ ◇□◇◇◇◇ + ■● △■◆◇◆□◇◇ + ◇▲ ◆○□□□△■ + ◇◇□□◇◇◆△◇ + ◇●●□●▲△◇○ + ○◆◆◇○●△△ + ■○■ ○○△ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_28.txt b/codex-rs/tui2/frames/shapes/frame_28.txt new file mode 100644 index 0000000000..16b349dc3d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_28.txt @@ -0,0 +1,17 @@ + + △□□● + ◇□◆◇◆ + ● ●◇◆ + ◇▲◆□◇ + ◇ ◇◇ + ◇ ▲■◇ + ○◇◇ ◆◇ + ◇▲■■◇ + △○ ◆◇ + ◇◇△△◇◇ + ◇ ◆◇ + ◇●□●◇ + ◇●□◇◇ + □◆◆△◇ + ◇ △□◇ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_29.txt b/codex-rs/tui2/frames/shapes/frame_29.txt new file mode 100644 index 0000000000..24be1563b2 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_29.txt @@ -0,0 +1,17 @@ + + □□□□□▲ + ▲◇□△ □○ + ○▲◇◇ ■■◇ + ◇◇◇◇○○▲△ + ○ ◇■◇ ◇ + ◇▲◇◇◇ ◆◆ + ◇○○◇◇◇ ○ + ◇◇●◇◇ + ◇◇◇□◇ ◆ + ◆◇●◇◇ ◆ + □◇◇△◇○ ● + ◇△◇◇◇ ◆◇ + ◇◇◇◇◆◇◆◇ + ◇●◇□△■◇■ + ◇◇□ △▲ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_3.txt b/codex-rs/tui2/frames/shapes/frame_3.txt new file mode 100644 index 0000000000..3f55b79ac5 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_3.txt @@ -0,0 +1,17 @@ + + ◆△●●□□●●●●▲◆ + ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ + ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ + △□△○◇●▲□◆ ◆●□ ◇◇○ + ■△□◇△○△◇◇□○◆ ○○○◇◇◆ + △◇○○△ △▲◇○●○○ ◇ ○○◇ + ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ + ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ + ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ + ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ + ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ + ■□◇ ○□ ◆ △○□○△△ + ●○△△□○▲ ▲□●△■▲◇■ + ●○□▲◆□□○●●●●▲△●□●◆◆□■ + ◆□□□◆△◇◆◆◆◆◇●△○■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_30.txt b/codex-rs/tui2/frames/shapes/frame_30.txt new file mode 100644 index 0000000000..54886a319d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_30.txt @@ -0,0 +1,17 @@ + + ◆●■□□□▲ + ◆△●◇◇○□■●○ + ■■○◇▲○○◇□▲○ + ◇◇○◇△◇◆○◇○ ○▲ + ●□◇◇◆○◇▲◇▲ ◇ + ◇□◇○□ □◇◇◇◇△◇ + ○△◇◇◇○◆ ○△○◇ ●◆ + ■◇ ■□●◇▲◇■◇▲ ◆ + ■◇◆■○◇●◇○△◇▲ ◆ + ■◇◇○◇◆◆◇◇ □▲ ◆ + ○◇◇●○◆□◇◇◇□◇◇ + ◇□△△▲△□○◇◇■ ◇ + ○◇○◇◇△□◇◇▲◇△ + ▲○▲□●○△◇▲△■ + ○▲△◇○◆◆●■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_31.txt b/codex-rs/tui2/frames/shapes/frame_31.txt new file mode 100644 index 0000000000..b3989b89df --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_31.txt @@ -0,0 +1,17 @@ + + ◆△△□□□□●▲ + ◆□□●□▲◆■□○■□▲ + ▲◇○△△▲●◆◆△■◇◆□○ + ▲◇△□◆◆□●◆▲○○◇○◇◇○ + ○◇◇◇◇□▲□○◆▲■○◇△◆◆ + ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ + △◇◇ ◇○□◇○△●△●□◇□◇△◇ + ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ + ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ + ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ + ○□◆△□◇▲ △□□□◆△◇■◇◆ + ◇◇◇○○○▲ ◆●□ ◇ + ●◇□●◆ ▲ △ ◇□◆△◆ + △◇○◇△◆◇◆▲◇■▲◇◆ + ■■●◇◆□□△△●□ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_32.txt b/codex-rs/tui2/frames/shapes/frame_32.txt new file mode 100644 index 0000000000..919eee3b0f --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_32.txt @@ -0,0 +1,17 @@ + + ▲□□□□●●◇▲◆ + ●□◇□□◇○◇◇□□◇◇▲ + △○□●○▲□○△▲◆□◇□○■□▲ + ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ + △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ + ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ + □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ + ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ + ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ + ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ + ▲○□◇◇△ ◇□■■□□□○●△△△△△ + ○▲◇○○○ ▲○◇△■◇ + ■□□◇◇◇□◆ ◆△◆◇△ ▲ + ○▲○■◇□◇●●□◆◆□■◆△ + ◇●●◆◆▲□△□◆■■ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_33.txt b/codex-rs/tui2/frames/shapes/frame_33.txt new file mode 100644 index 0000000000..c5598aa7a7 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_33.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●□●◇△◆ + ◆□□◇◇○□●◇△◇▲◇□◇□△ + ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ + ▲○△◇△◆◇▲◆ □◇◇○◆□ + ◆△◇△◆◇◇△○○○ ◇□○○○ + △△▲ ○◆○○◆○▲ △○△◆○ + ◇◇□ ○▲□◇◆◇○ ◇□◇◇ + □◇▲▲ ▲■■○△●▲ ○■◇□ + ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ + ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ + ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ + ○□○□▲■▲ △△●△●△ + □○■□▲□●▲ ◆□◇▲△□○■ + ○◇■□○□◇▲●□◆■●▲○□◇□ + ■●●□▲◆◆◆○□□●▲■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_34.txt b/codex-rs/tui2/frames/shapes/frame_34.txt new file mode 100644 index 0000000000..5a44de8256 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_34.txt @@ -0,0 +1,17 @@ + + ◆▲●□□□□●◇▲◆ + ◆□□□△○●◇■◇□□●□□●▲●◆ + ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ + ▲△◆◇□◆△▲◆ □◇▲□○○ + △△◇○▲△◇ ◇■ ▲ ○○□○○ + ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ + ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ + ○◇●■△ ◇□◆◇△■◇ △ ◆○ + ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ + ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ + △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ + △▲○●●○◆ △●△◇△■ + ○□◆◇□○●▲ ◆□●■◆△▲◆ + ▲○△◇■◇□▲●▲□●□●□◆□□△■ + ■◇○●●◆◆◆◆◆●●□●○ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_35.txt b/codex-rs/tui2/frames/shapes/frame_35.txt new file mode 100644 index 0000000000..1c1728676b --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_35.txt @@ -0,0 +1,17 @@ + + ◆▲□●●□□□●□▲◆ + ▲□●□△◇□□□■◇□◇△■◇□●◆ + ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ + ▲△△□△▲●▲◆ □□◆○◇◆ + △△◇◇△△○ ■○□▲ ◇○□◇▲ + ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ + ○ △△ ○○ ■□◆△ ◇○○○ + ◇◇◇△ △○○●△■ △ ■△ + △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ + ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ + △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ + ■△○○△◇ ◆△□ ▲○ + ○●◆○●▲△▲ ▲●△◆□□■ + ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ + ■○◇●●◆◆△◆◆□□□■○ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_36.txt b/codex-rs/tui2/frames/shapes/frame_36.txt new file mode 100644 index 0000000000..0cac995ed7 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_36.txt @@ -0,0 +1,17 @@ + + ◆◆□●□□□□●□▲◆ + ◆●■■○□△●△□○□●□□■□□□◆ + ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ + ◆◇■□□◆◆◆◆ ■□◇○■◇ + ▲△◆●○■◇○◆○▲△ ○○△□ + △ ▲△◆ □◆ ■◇△▲ ■○○□ + ◇ ● ◆ ○ ■○○ ◇○◇▲ + ◇◇○◇ ◆○ ◆△△ ○◇ ◇ + ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ + ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ + ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ + ■▲■○●△ ▲△◇■△ + ○△◇◇□◇▲ ▲□□■△▲◆ + □▲◆■□□●○●◇◆□◇●■▲■▲●□■ + ◆□□△◇◆▲▲◆▲▲□□□□■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_4.txt b/codex-rs/tui2/frames/shapes/frame_4.txt new file mode 100644 index 0000000000..31e55f9cb8 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_4.txt @@ -0,0 +1,17 @@ + + ◆△●●□●●□●□▲◆ + ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ + ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ + △△◇▲◇■△□▲ ■◇□□○□○ + △◇△◆◇●◇○●○◇▲ ○■○◇□○ + △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ + ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ + ◇△○ ▲◇△□○◇◇ ◇◇◇ + ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ + ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ + ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ + ▲◇○■◇◆◆ ▲○●△□□■ + ○○▲△○ ▲◆ ◆△○▲□●◇△ + ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ + □○○◇△◇◆◆◆◆◆●▲□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_5.txt b/codex-rs/tui2/frames/shapes/frame_5.txt new file mode 100644 index 0000000000..a8ae0ab819 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_5.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●●□▲◆ + ◆□■□△□△◇◇□◇●□●◆◇■□▲ + □◆△○□◇□■◆ ■○●◇□○ ▲□▲ + ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ + ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ + ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ + ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ + ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ + ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ + ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ + ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ + ○○□△○○ ◆ △◆△□▲○ + ○△□○◇●□◆ ◆●□▲□■●□ + ○◇□◇▲□□○●◇●◆▲□●□○◇●□ + ○▲□◆□■◆◆◆▲◆▲□□◆ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_6.txt b/codex-rs/tui2/frames/shapes/frame_6.txt new file mode 100644 index 0000000000..e0b1f85454 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_6.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●□□◆◆ + ▲○●△□◇●□□◇□□●◆▲□□◆ + ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ + △▲◇□◇□◆●▲ ○□◇◇◇◇▲ + ■●◇◇◇□○○□□○ △○○◇○▲ + ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ + ◆ □◇◇ ○○◇△○○ ○□◇◆◇ + ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ + ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ + ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ + △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ + ▲ ● △▲◆ ▲△◆△△◇◆ + △◆○◆□□▲ ◆□○◆△□▲□ + ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ + ○▲●□□▲◆◆◆◆◆▲□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_7.txt b/codex-rs/tui2/frames/shapes/frame_7.txt new file mode 100644 index 0000000000..7e69d68d57 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_7.txt @@ -0,0 +1,17 @@ + + ◆△◇●□□●●□▲◆ + ▲□◆□○◇□□□●●○● ◆□▲ + □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ + ○○◇◇◇◇●□ ○◇○□◇△▲ + ◆■◇□◇◇○◇△○▲ △ □◇◇○ + ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ + ◆○■◇ ○◆◇○◇○◇ □□○◇◇ + ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ + △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ + ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ + ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ + ■◆ △◆△◆◆ ▲■▲△●△■ + ■●▲□○○○◆ ▲○◇▲□△□■ + ○◆■○◇□○●◇◆◆◆□□◇□□△ + ■◇□■◇△◇◆◆▲◆●□□ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_8.txt b/codex-rs/tui2/frames/shapes/frame_8.txt new file mode 100644 index 0000000000..b7bddd4156 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_8.txt @@ -0,0 +1,17 @@ + + ◆◆□◆●□□●□▲◆ + ▲■□△◇◇◇□●● ◇ ■□ + ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ + ◆□○□△◇▲□◇ ■ ●○◇◇○ + ■ △◇△▲○◇△○ △■○◇◇▲ + ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ + ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ + ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ + ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ + ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ + △△○○◇○◇◇◇△■○□○□■○□■△○◇ + ■▲■◇○◇◆◆ △▲△△△■ + ■○◆□○○○◆ ◆△○◆◇□△□ + △△■◇▲□ ●●□□●△●▲◇◆ + ◇◇◆□□○◇◆▲□△●◆ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_9.txt b/codex-rs/tui2/frames/shapes/frame_9.txt new file mode 100644 index 0000000000..4342d3c81e --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_9.txt @@ -0,0 +1,17 @@ + + △●○□△□□●▲ + △□◆△◇◇□■● ◆▲○◆ + △ ▲◇◇△□○○●△ ○◆◇▲ + ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ + △□■●○□■◇□▲ □△■□△□◇◇ + ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ + ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ + ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ + ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ + ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ + ■ ◆○△○○◇△■□□□□◇◇■△○◇ + ○◇◇◇○▲◆ ▲△ ○△◇◆ + ○□○◇○○ △●◆●◇△□ + ○▲■○◆◇□△□●●●□◇◆ + ■▲◆ □○○◇●□△■ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_1.txt b/codex-rs/tui2/frames/slug/frame_1.txt new file mode 100644 index 0000000000..514dc8ac49 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_1.txt @@ -0,0 +1,17 @@ + + d-dcottoottd + dot5pot5tooeeod dgtd + tepetppgde egpegxoxeet + cpdoppttd 5pecet + odc5pdeoeoo g-eoot + xp te ep5ceet p-oeet + tdg-p poep5ged g e5e + eedee t55ecep gee + eoxpe ceedoeg-xttttttdtt og e + dxcp dcte 5p egeddd-cttte5t5te + oddgd dot-5e edpppp dpg5tcd5 + pdt gt e tp5pde + doteotd dodtedtg + dptodgptccocc-optdtep + epgpexxdddtdctpg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_10.txt b/codex-rs/tui2/frames/slug/frame_10.txt new file mode 100644 index 0000000000..bd3b8fafff --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_10.txt @@ -0,0 +1,17 @@ + + dtpppottd + ppetptox5dpt + ddtee5xx-xtott + edd5oecd-otppoot + 5 ceeged pt5d5e5 + ee pepx55o gedge + o xpgpeexep e5t + g eeot5tee-de-oee + g xo ooecxxtotcee + e teoted5dpdddepe + t geeeeegggotgoee + oeptotpg dxggt55 + ep eeexptct5e5e + cepp5etcdg55p + pt dpodtcp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_11.txt b/codex-rs/tui2/frames/slug/frame_11.txt new file mode 100644 index 0000000000..9eaf147a6a --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_11.txt @@ -0,0 +1,17 @@ + + tppppttd + 5g ceeeoot + 5gddeecop5ot + eddeeoctdo55 + dg-coetopcdeet + eteeetdcced5ee + pp teeeeeedeoo + e ee5eeo5 ege + 5 pe5eep5tede + pp de5otg5eded + pe- eeeeo5eooe + od-5edp5ppcee + gd peooddecg + otgpeetd5pe + od pptdte + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_12.txt b/codex-rs/tui2/frames/slug/frame_12.txt new file mode 100644 index 0000000000..11163a99b9 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_12.txt @@ -0,0 +1,17 @@ + + tpppt- + toed5eto + g e5ott + 5txeeooge + e pxeee-5e + ep--pdgdeg + e x5oeeo + e 5toeeg + pt x5eetex + e 5epcpd + e- egopp5 + t pegdte + 5 ppetpoe + pd5gteoee + o pxo-5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_13.txt b/codex-rs/tui2/frames/slug/frame_13.txt new file mode 100644 index 0000000000..eb072e40ad --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_13.txt @@ -0,0 +1,17 @@ + + 5pppt + eddee + eedeg + epped + p ee + gc-ee + t ee + t ge + 5t dx- + eg toe + pe-- xe + eddde + etted + pddeo + t -go + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_14.txt b/codex-rs/tui2/frames/slug/frame_14.txt new file mode 100644 index 0000000000..100f309302 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_14.txt @@ -0,0 +1,17 @@ + + tpppc + t5dd-o + edee- e + ee5egdxt + eeeo e + xpee pe + eeee - p + eeoee o + eeex g + gd55 c5 + oeggt-te + epxeddde + 5ggeoooe + eo5pdd5 + dp po5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_15.txt b/codex-rs/tui2/frames/slug/frame_15.txt new file mode 100644 index 0000000000..5761f309d4 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_15.txt @@ -0,0 +1,17 @@ + + ttpppxd + etoeedcpt + 55epooegpe + e5e 55t-dde + eeogooee5gde + oee-ee55e g + ee eeeexxte + ee5xeteee p-e + eetttpeeed-ce + edec5eoxp- -e + e5eeeede e c + epp-dxeo- o + peot 555e ce + edeeoo-to5 + odpdddd5 + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_16.txt b/codex-rs/tui2/frames/slug/frame_16.txt new file mode 100644 index 0000000000..f9001140ed --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_16.txt @@ -0,0 +1,17 @@ + + dotgpptxd + 5deepeeoeoo + e55go5ooee po + 555 cpdoeoeeppe + eecd5ppoeep5eeo + eeepep eoo ge x-e + ooe 5eteeee5pgt e + e5c eeee5eeegc ee + ooexetx5deegpt 5 + pgddtooope-de tde + eeetgg5poecgc-xee + eep ee e55t 5 + oep e 5t5dte + oexgdpx55tde + pc-docddcp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_17.txt b/codex-rs/tui2/frames/slug/frame_17.txt new file mode 100644 index 0000000000..696d932d40 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_17.txt @@ -0,0 +1,17 @@ + + totttccxtd + dcppexxpopetgdt + tpo5dooettgeeedgo + 5e5d5egde pecoxeeoo + e5d x eg5ooo55 t + eopt5e tc5e 5to5e-5 + pgc5e t55ed pgee5oe + -goeg g55ee5eteeocp + t5p5oootxodeodcoeee e + egdcdde5po5eeogotpto + ooo5gggppppodep55o op + oeedp e5eee c + doogpod tpt5dd5 + t xptootedcpcep + etc5dttxpdtp + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_18.txt b/codex-rs/tui2/frames/slug/frame_18.txt new file mode 100644 index 0000000000..abb0da53d2 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_18.txt @@ -0,0 +1,17 @@ + + dootootcxtd + dtgdctttxddtgtccd + d5cepgpogtg-dgt55o p + teep-tdgp poed5oxocooot + do55 5e dgtdo5x5edocood + 5oe ttx-ddd5tptep-5d5e5xg + eeoc5 t5p5egd5gpeoeot + go-xe dogeecd eceg + ogg tooococtxetep5ot epee + dooepop xe5ddodxeedcxeo t + e5eoeggpppppe odd5e5p5e-e + oogtot eepg5pdp + odptdd- dpdd5ote + pe-ppttooodppd5dtp + gxed5ddt-tgctg + e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_19.txt b/codex-rs/tui2/frames/slug/frame_19.txt new file mode 100644 index 0000000000..ffc4d2b475 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_19.txt @@ -0,0 +1,17 @@ + + ddootxtoox-d + dteeeo5ooodpdteptt + tpcc5getpe epctepco + 5ceptde doottdept + ee5e5e tedg5geo eot + eo5pp depx5g-5 p-pe + doexp 5pd5ette 5c5te + eeee ecgoegt e eee + epeotoccoooxxxetpcpec o gee + dc5teop5dptotet dd codot5-ed + pog5tegggggppg dod5e55 55p + oodpdo e55d55p + pgdoxxpt tco-5ece + pg-ep5xtddoc5pg cpxp + gx-dc-pdt-dp-d + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_2.txt b/codex-rs/tui2/frames/slug/frame_2.txt new file mode 100644 index 0000000000..f4419e3d69 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_2.txt @@ -0,0 +1,17 @@ + + d-dcotooottd + dtt5pcteexoxeodpeptd + tepeoppxpee egpop5eecet + de5d5ppttd -toe5et + tdg5pdeodood dteoet + p5tge epot5ot ooepe + teppe d5ecedet 5gege + eg oe tepeecp ep5-e + pggoe cedddeg-xtttttttttedexp + dope 5eep 5p eoodddd--ddet5geg + ooo p po--ep egpppppppgetpee + pod-5t e ttc5tp + -oddett todtgdeg + exdcddgptccocc-opedeep + eptptxxddddxc5pg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_20.txt b/codex-rs/tui2/frames/slug/frame_20.txt new file mode 100644 index 0000000000..0039bd880b --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_20.txt @@ -0,0 +1,17 @@ + + ddtoxxototdd + tcogctdtoooeeddpott + ttgdtpgxpg egdgotpetd + degdpep dtteo5poo + de tep d5gdeedpoeeo + 5etep tppceg5 poxeo + cecte tp -tpd toe5 + edd5e ggccegt exge + e5eectocoooooodtpcddoop 5 eo + pededg 5eeddddeo poogeoo t5 ee + otdopgggggggpg otoeete 5o + p dp5t d5p-e5 + oddptxd tcpecpp + dt--gxtdcctcgxget5pp + eptxdgoddddepgg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_21.txt b/codex-rs/tui2/frames/slug/frame_21.txt new file mode 100644 index 0000000000..87e3597d5d --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_21.txt @@ -0,0 +1,17 @@ + + ddtotootottdd + ttpeeddtxoxtcde-ptd + cpddtpdge edxptdept + tpecgp dtcptcpt + 5t5pe te5do ooddt + 5g5e tppd55 oodt + epee dg5et5p ocog + eo oc get e e + e e ttcccccttt detget c5 e + e xo ed dte dgepet 5g-5 + c g- eeggg pe ppdtc 5e t + pt ccd dpg 5 + pd d-d d-cpp te + pod pgptcxxccopgg -e + pttctddddtdctpe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_22.txt b/codex-rs/tui2/frames/slug/frame_22.txt new file mode 100644 index 0000000000..8dfe7daaab --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_22.txt @@ -0,0 +1,17 @@ + + dttotootottd + dt5pe-d5e5oeecet5ptd + tcpcxoppe egpopptddtt + cpxppg tteeedpo + oex5p td5eoe-5cpe + oep5e tx5ecd5e oeooe + etpo5 xteop5p xe5e + eeoee eoexdo edege + eetgt txoocccottdopedgot decgg + deo pdootg5tgx55e opcdgettg5oo + ooode gggggppge ecptd555gte + opodot dptgetp + pgtc ttd d-ptgpd5 + pcpttgpxcotcc5opggptp + gxgxdcddd-od-ope + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_23.txt b/codex-rs/tui2/frames/slug/frame_23.txt new file mode 100644 index 0000000000..f573acb714 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_23.txt @@ -0,0 +1,17 @@ + + dttdotpttotdd + doeteodtdootedepetd + tgteptcpe gxcteoceet + 5cepc5e dccoeeco + 55ee5p t5ttpp5poeo + toeg5e dppppp5ppexoet + eeege 5c5- dee ggepp + x5e e egetdot e p5e + dgo etxcooooocoedpedgod e exe + eoodegog 5eo oedotx ode + 5pe e eggggggg pe5coed5d5e + teede- t5eod5e + o etp5dd d-gcp5t5 + oootcptoxoodttg-dtpe + gxgpooxddtddppe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_24.txt b/codex-rs/tui2/frames/slug/frame_24.txt new file mode 100644 index 0000000000..92833e8c58 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_24.txt @@ -0,0 +1,17 @@ + + dxdcttpootd + tgd5geddxoottdpt + tgpco5tgx -ecpxppt o + tp55t5eotoex5egdpoeodp + t 55o5t egg5odeeeoetp + xtotoe ptt5g-ecg5go + e exg t5dt5g5doeoded + e oee eto5odexd5-eee + e- eccccttxxxttdptde e ee + d 5 gpe5ttcctte-dotpe epe + o poeopgggggge ot-poeo5te + o otpo egg5c5 + o-poee- dogd5cdp + --ptodgxxcoocedd5e + pcdptcoddootp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_25.txt b/codex-rs/tui2/frames/slug/frame_25.txt new file mode 100644 index 0000000000..d8b8655dac --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_25.txt @@ -0,0 +1,17 @@ + + dtopcttttd + tgptpedcoepeet + 5e55ttg-etoooeeed + etpe 5g oe goetpo5 + tddot5pdc5deg e55o5p + otxexdpt-dec 5ete55et + e5epe edd5od5eo5dgeoe + eee5e-ggxdoo5eodxoeeo + dtoegeddooootxeooetpeo + 5-ge5etedeecpdeopo5oe + oxp5oeegggggpt5eoe5ee + edgectco-tpcd5t55e5 + dededodpc5td5dee5 + o-coodpoeppgpep + xtgpottdtep + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_26.txt b/codex-rs/tui2/frames/slug/frame_26.txt new file mode 100644 index 0000000000..4be73d44de --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_26.txt @@ -0,0 +1,17 @@ + + dcpppgtt + 5pec5oetcet + 5pggecoeoggot + cpgteexdeedt5d + edtoe-xgpo5ceoc + o ge5c5gpdogoo5 + eg ppoee5eeccgt + 5- oeeeddoee5ee + e -opctxtoo5oee + g -de5teddtpoope + eeg5epgot5etoee + odxe5o 55geee + p peo de5e5t + egpoogpdppc + o5cdpxdop + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_27.txt b/codex-rs/tui2/frames/slug/frame_27.txt new file mode 100644 index 0000000000..f333909d2b --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_27.txt @@ -0,0 +1,17 @@ + + cppptt + ecc5e5o + cpe pe5pe + exxdeecex + e eed-po + xd-dgeeeee + o geedpeeg + e eeexogee + e- -geteeee + po -gdedpee + e- ddppt5p + eetteed5e + eootot5ed + oddeoo55 + pog do5 + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_28.txt b/codex-rs/tui2/frames/slug/frame_28.txt new file mode 100644 index 0000000000..3c0deb542c --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_28.txt @@ -0,0 +1,17 @@ + + 5ppc + etdee + o cee + e-epe + e xe + e -ge + dex de + e-gge + 5o de + ee-cxe + e de + eotoe + eopee + pdd5e + x -te + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_29.txt b/codex-rs/tui2/frames/slug/frame_29.txt new file mode 100644 index 0000000000..0c6277f4d5 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_29.txt @@ -0,0 +1,17 @@ + + tppppt + tep5gpo + dtee pge + eeeedot5 + o xge e + etxee dd + eooeee d + eeoxe + eexpe e + deoee e + pee5ed o + e5xxe de + xeeeexde + eoep5gep + xep -t + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_3.txt b/codex-rs/tui2/frames/slug/frame_3.txt new file mode 100644 index 0000000000..b1e9173608 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_3.txt @@ -0,0 +1,17 @@ + + d-octtooootd + dtt5oeetegooecddeptd + tc5pcepgge egxgppt5det + 5t5oecttd eopgeeo + p5pe5d5eepod odoeed + 5eoo5 -teocoo e ooe + op e ppoedget -p5et + t-ete t-eg5oe xdeoe + -gpe ceptxep-xottdddttdxdgce + tocot -5p5cce epeddtgo-tcoeeoee + pde e geettg gpppgggppt555t5 + ppx ot e 5dtd55 + od55pot ttc5gtep + odttdppdococtcopcedtg + eptpdcxddddxc5dg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_30.txt b/codex-rs/tui2/frames/slug/frame_30.txt new file mode 100644 index 0000000000..9dfd28bc20 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_30.txt @@ -0,0 +1,17 @@ + + dcgpptt + d5ceeoppoo + gpdetooetto + eeoe5edoeo ot + opeeeoetet e + epedt peeee-e + o5eeeod o5oe oe + ge ptoxtege- e + gedgoxoxo5et e + geeoeddxegtt e + goeecodpxeetxe + ep55t5poeeg e + oeoee5pxetx5 + tdttod5et5p + o--edddcp + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_31.txt b/codex-rs/tui2/frames/slug/frame_31.txt new file mode 100644 index 0000000000..1dba8edd8f --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_31.txt @@ -0,0 +1,17 @@ + + d-cptptot + dtpcttdgtoppt + teo55tode-gedpo + tx5tddpcdtooeoxeo + deeeet-podtgoe5dd + cedexdepgocpt-5etge + 5ee edpeo-o5cpepe5e + oot exgeo edggexo-e + eotdepdxex5txxed e + geex55eedddodeoee p + dpd5tet 5pppe5epxdg + eeeooot dop e + cepodgt - epe5e + ceoe5deetegtee + pgoxdtp5-cp + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_32.txt b/codex-rs/tui2/frames/slug/frame_32.txt new file mode 100644 index 0000000000..33160e7163 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_32.txt @@ -0,0 +1,17 @@ + + tttttccxtd + cpxppxoeeppext + 5dpodttdc-epepoppt + p55edtec- - tdoettgt + 55etepoppec petxo5opot + o5ootopeeceetd et5 p + pegctepoeettetoe5d55epd + eeetd gdeeg5pec-dgoegge + oee-d pdegddetttxxxoegoe + pggdeepopodddddeepecx-d + topee5 epggpppdc555-5 + otedoo toe5px + pppeextd d5de5 t + o-ogxtecopedtgd5 + xcoddtt5pdgg + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_33.txt b/codex-rs/tui2/frames/slug/frame_33.txt new file mode 100644 index 0000000000..ff8827f3d2 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_33.txt @@ -0,0 +1,17 @@ + + dttototox-d + dtpeeopoxce-epep- + d5oetpttoge gp5eetgt + to5e5detd peeodp + d-e5eee5odo etood + 55t odooeot 5o5do + eet g otpedeo epee + pett tppo5ot ogep + eee e te 5ptttcootxxt5deox + d5epg5ct5exedddddeepdddxe- + ooot e5e5 5 ggggppp eet5de + otoptgt 55c5o5 + pogptpct dtet5pop + oxgpotetctdgctopxp + goottddddtpc-g + eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_34.txt b/codex-rs/tui2/frames/slug/frame_34.txt new file mode 100644 index 0000000000..4b1eb6a5a2 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_34.txt @@ -0,0 +1,17 @@ + + dtottttoxtd + dtpt5ocegxtpoppctod + d55tepgcxpe edootgppt + t5depd5td petpoo + 55eo-5egeggt oopod + t5oee5 oogeopo otoeo + epdxd podpedd ecxd + oeogc epde5ge 5 do + tp5-g 5o55gdoottttttxddg xde + eeot5ttdg55pxeedx-dddt5deeeeo + 5dot eoepdg gppeeeed t5toep + 5tocood 5c-e5p + oteetoct dtogd5te + -o5xgettcttopopetpcp + gxocodddddcopod + eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_35.txt b/codex-rs/tui2/frames/slug/frame_35.txt new file mode 100644 index 0000000000..f2432dc0ad --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_35.txt @@ -0,0 +1,17 @@ + + dttcotttottd + tpop-xpptgepxcgxpod + d55ectpedpge egpop-gept + t55t5tctd ptdoed + 55xe55o gopt eopet + c5c5te pedg--pd eooe + o g-5 oo ppd- edoo + xexc 5ooc5p 5 g5 + 5 td tee5cg5eoodcdotxx exop + etdcp 55cgpt5ec otdddd5gx5p ep + 5eotp5ode5de egddddpddp55dte + g-do5x d5p td + ocedctct tc5dppp + gddgxpx-cxxtxc5pgd5cg + gdxcodd5ddttpgd + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_36.txt b/codex-rs/tui2/frames/slug/frame_36.txt new file mode 100644 index 0000000000..c84a104e4a --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_36.txt @@ -0,0 +1,17 @@ + + ddtottttottd + doggot5c5totcttgpptd + topottp-pgee egpxptetpet + degptdddd ppxoge + t5dcopeoeot- do-p + 5 t5e pd ge5t godp + e cge go goo edet + eeox do d55g oe e + epge 55 tpgptttdtttttd eoxe + dpeo tedd5x5 gexdddddddee o5pe + p peo tdt5d gppdddddg etg5 + ptgoc- t5eg5 + o5eetxt tttg5te + ptdgppodcxdtxcg-gtctp + ept5xdttdttttppg + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_4.txt b/codex-rs/tui2/frames/slug/frame_4.txt new file mode 100644 index 0000000000..2eed2c8465 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_4.txt @@ -0,0 +1,17 @@ + + d-octootottd + d-gtpe5geoo5pceg5ptd + tpoeeetpge edxodpe5ood + 55eteg-tt geppopo + 5e5deoeocdet ogoepo + 5eede pdee5po p ooet + goece pppotget t gce + e-o te5pdee eee + deged ce5gd55txtttddddtt eep + eo5ge t555tdeeooet-dtc5dce55ge + eecpotooto5 gppppppppd-eeee + teogede tdc5ppp + ootcdgtd d-dtpce5 + xeeodppoccocttoptoepe + pooxcxdddddc-pe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_5.txt b/codex-rs/tui2/frames/slug/frame_5.txt new file mode 100644 index 0000000000..e0c7693a9e --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_5.txt @@ -0,0 +1,17 @@ + + d-occtooottd + dtgt5p5eetxotcdegtt + pd5otepge gdoepogtpt + te5e5e-ot -deeeed + to5p5ddtedot e oeoed + xeee5 odeoc5ed t5ee + e5egt eodeoget ppxtet + e5edg tdoe5de tede + etedp 5degtepodxtxdddttdge-e + doeott5tpg egg55oodto-t5e5pee + oteetxtoo5te gppgggggtxceo5 + oot5oo e 5d5ptd + d5poeotd dottppcp + depetptocxodttopdxcp + d-pdpgddd-dttpe + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_6.txt b/codex-rs/tui2/frames/slug/frame_6.txt new file mode 100644 index 0000000000..d5ac091f39 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_6.txt @@ -0,0 +1,17 @@ + + d-occtoottdd + tdc5peoptettcdtptd + ttteeepg egx-optp5od + 5tepepdot oteeeet + poeeepootpo -ooeot + c-5e5edtceodet -oo5e + d txe gdoe5do dtede + x exe deox5ee xtoee + d-e-e 5peepc5tdxtxddttd-o5e + eeot5dddct e5opteetcoooeteog + 5 e 5xeec5g gpgggppgeoo5e + t c 5te tcd55ee + -eodppt dtdd5ptt + egxptgtgcxddttppgccp + d-cpttdddedttp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_7.txt b/codex-rs/tui2/frames/slug/frame_7.txt new file mode 100644 index 0000000000..02d1f1ae52 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_7.txt @@ -0,0 +1,17 @@ + + d-xcptoottd + tpetoetptooocgept + p teeepe edxegp5dpt + ooeeexct dxope5t + epepeede5ot - peeo + e ceeg epoceo t-eee + eoge ddeoeoe ppdee + dg5xe ot5epee p eee + 5gxp tgd5eo5xxccdxxocpoee + etpeeocxe5pe-oeeoototcoeoe + o od5pepd5 ppppppggd5ee + pd 5d5de tg-5c5p + pcttoood tdxtp5pp + odpoepocxdddtpept5 + gxpgxcxddtdcpp + \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_8.txt b/codex-rs/tui2/frames/slug/frame_8.txt new file mode 100644 index 0000000000..d028ab360e --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_8.txt @@ -0,0 +1,17 @@ + + ddtdcttottd + tgp5eeepoogx gt + deote55pgtgx5xpotdt + dpop5ette p odeeo + p 5e5toe5o -poeet + epteodddoedp oteee + epeee pdcoeee ede5et + otee5 eto5etp- -e5ee + o oeedd g5poex5tttttoxee + g op5odegteteeceoddeeop + 55ooeoeee5pdpdpgopg5oe + ptgeoeee -t555p + podpoood d5odet5p + -cpetpgcctpc5otee + xxdppoedtt5oe + ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_9.txt b/codex-rs/tui2/frames/slug/frame_9.txt new file mode 100644 index 0000000000..2481e07a35 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_9.txt @@ -0,0 +1,17 @@ + + -odp5ttot + 5pd5eepgogd-od + 5 tee5pddo5godxt + g ee5cod ddteodett + 5pgcopgept p-ptctee + e eeegdeocdd epepeee + e xpoeppeootg-t5 eee + e x5dtoxeed5oode gee + g gteg 5egexxetexteee + edeeedtededeeeeddeee + g ed5ooe5gppppeeg5oe + oxeeote t5 d5ee + otoeoo 5cdce5p + d-godep5toccpee + p-d pooect5g + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_1.txt b/codex-rs/tui2/frames/vbars/frame_1.txt new file mode 100644 index 0000000000..0ca3a5d334 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▉▌▌▉▊▎ + ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ + ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ + ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ + ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ + ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ + ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ + ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ + ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ + ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ + ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ + ▉▍▊ █▊ ▎ ▊█▋▉▎▏ + ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ + ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ + ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_10.txt b/codex-rs/tui2/frames/vbars/frame_10.txt new file mode 100644 index 0000000000..b422fb1274 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▎▉▉▉▉▍▉▉▎ + ▉█▎▉▉▉▍▏▋▎▉▊ + ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ + ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ + ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ + ▏▎ █▏▉▏▋▋▍ ▎▎█▏ + ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ + █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ + █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ + ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ + ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ + ▍▎█▊▍▊▉█ ▎▏██▊▋▋ + ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ + ▌▎▉▉▋▏▉▌▎ ▋▋█ + ▉▊ ▎▉▍▎▊▌▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_11.txt b/codex-rs/tui2/frames/vbars/frame_11.txt new file mode 100644 index 0000000000..5d4524e293 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▉▉▎ + ▋█ ▌▏▏▏▍▍▊ + ▋█▎▎▏▏▌▍▉▋▍▊ + ▏▎▎▏▏▌▌▊▎▌▋▋ + ▎█▋▌▍▏▉▍█▌▎▏▏▊ + ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ + ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ + ▏ ▏▏▋▏▏▍▋ ▏█▏ + ▋ █▏▋▏▏▉▋▉▏▎▏ + ▉▉ ▎▏▋▌▊█▋▏▎▏▍ + █▎▊ ▏▏▏▏▌▋▏▍▍▏ + ▍▎▊▋▏▎▉▋▉▉▌▏▎ + ▎ ▉▏▍▍▍▎▏▌█ + ▍▉ ▉▏▏▊▎▋▉▎ + ▍▎ █▉▉▎▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_12.txt b/codex-rs/tui2/frames/vbars/frame_12.txt new file mode 100644 index 0000000000..f81900edb1 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▋ + ▊▌▎▎▋▏▊▍ + █ ▏▋▍▉▊ + ▋▉▏▏▏▌▌█▏ + ▏ █▏▏▏▏▋▋▏ + ▏█▋▋▉▍█▎▏█ + ▏ ▏▋▍▏▏▍ + ▏ ▋▉▍▏▏█ + ▉▊ ▏▋▏▏▉▏▏ + ▏ ▋▏▉▌▉▎ + ▏▋ ▏█▌▉▉▋ + ▊ ▉▏ ▎▊▏ + ▋ ▉▉▏▊▉▍▏ + █▍▋█▊▏▍▏▎ + ▍ █▏▍▋▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_13.txt b/codex-rs/tui2/frames/vbars/frame_13.txt new file mode 100644 index 0000000000..4231032a45 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▋▉▉▉▊ + ▏▎▎▏▏ + ▏▎▎▏█ + ▏▉▉▏▎ + ▉ ▏▏ + █▋▊▏▏ + ▊ ▏▏ + ▉ █▏ + ▋▉ ▎▏▋ + ▏█ ▉▌▏ + █▎▋▋ ▏▎ + ▏▎▎▎▏ + ▏▉▊▏▎ + ▉▎▎▏▌ + ▊ ▋█▌ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_14.txt b/codex-rs/tui2/frames/vbars/frame_14.txt new file mode 100644 index 0000000000..6eab794e0a --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▌ + ▊▋▎▎▋▍ + ▏▎▏▏▋ ▏ + ▏▏▋▏█▍▏▊ + ▏▏▏▍ ▏ + ▏▉▏▏ █▏ + ▏▏▏▏ ▋ ▉ + ▏▏▍▏▎ ▍ + ▏▏▏▏ █ + █▍▋▋ ▌▋ + ▍▏██▊▋▊▏ + ▏▉▏▏▎▎▎▏ + ▋ █▏▌▌▌▎ + ▏▍▋▉▎▎▋ + ▎▉ █▌▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_15.txt b/codex-rs/tui2/frames/vbars/frame_15.txt new file mode 100644 index 0000000000..fa9a859bd0 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▏▎ + ▎▊▌▏▏▍▋▉▊ + ▋▋▏▉▌▍▏█▉▏ + ▏▋▎ ▋▋▊▋▎▎▏ + ▏▏▌█▌▍▏▏▋█▍▏ + ▍▏▏▊▏▏▋▋▏ █ + ▏▏ ▏▏▏▏▏▏▊▏ + ▏▏▋▏▏▉▏▏▏ █▊▏ + ▏▏▉▊▊▉▏▏▏▎▋▋▏ + ▏▎▏▌▋▏▍▏▉▋ ▋▏ + ▏▋▏▏▏▏▎▏ ▎ ▌ + ▏▉▉▋▍▏▏▍▊ ▌ + █▏▍▊ ▋▋▋▎ ▌▎ + ▏▍▏▏▍▍▋▉▍▋ + ▍▍▉▍▎▎▎▋ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_16.txt b/codex-rs/tui2/frames/vbars/frame_16.txt new file mode 100644 index 0000000000..1fcc2090a2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▎▌▉█▉▉▉▏▎ + ▋▍▏▏▉▏▏▍▏▌▍ + ▏▋▋█▍▋▌▍▏▏ █▍ + ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ + ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ + ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ + ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ + ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ + ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ + ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ + ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ + ▏▏▉ ▏▎ ▎▋▋▊ ▋ + ▍▏▉ ▏ ▋▊▋▎▊▏ + ▍▏▏█▎▉▏▋▋▉▎▏ + █▋▋▎▌▋▎▎▌▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_17.txt b/codex-rs/tui2/frames/vbars/frame_17.txt new file mode 100644 index 0000000000..1adf01af90 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▊▌▉▉▉▌▌▏▊▎ + ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ + ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ + ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ + ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ + ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ + ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ + ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ + ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ + ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ + ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ + ▍▏▏▎▉ ▎▋▏▏▎ ▌ + ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ + ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ + ▎▉▌▋▎▊▉▏▉▎▉▉ + ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_18.txt b/codex-rs/tui2/frames/vbars/frame_18.txt new file mode 100644 index 0000000000..9c46c64821 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▎▌▌▉▌▌▉▌▏▊▎ + ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ + ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ + ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ + ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ + ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ + ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ + █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ + ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ + ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ + ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ + ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ + ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ + █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ + █▏▏▎▋▎▎▊▋▉█▌▉█ + ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_19.txt b/codex-rs/tui2/frames/vbars/frame_19.txt new file mode 100644 index 0000000000..572f5ffc32 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▎▎▌▌▉▏▉▌▌▏▋▎ + ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ + ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ + ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ + ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ + ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ + ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ + ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ + ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ + ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ + █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ + ▍▍▍▉▎▍ ▎▋▋▎▋▋█ + █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ + ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ + █▏▋▎▋▊█▎▊▋▍▉▊▍ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_2.txt b/codex-rs/tui2/frames/vbars/frame_2.txt new file mode 100644 index 0000000000..0e0c021f43 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▌▌▌▉▊▎ + ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ + ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ + ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ + ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ + █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ + ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ + ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ + ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ + ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ + ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ + █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ + ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ + ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ + ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_20.txt b/codex-rs/tui2/frames/vbars/frame_20.txt new file mode 100644 index 0000000000..42c288df92 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▏▏▌▉▌▊▎▎ + ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ + ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ + ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ + ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ + ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ + ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ + ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ + ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ + █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ + ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ + ▉ ▍▉▋▊ ▎▋▉▋▏▋ + ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ + ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ + ▎▉▉▏▎ ▌▎▎▎▎▏▉██ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_21.txt b/codex-rs/tui2/frames/vbars/frame_21.txt new file mode 100644 index 0000000000..aa5d4f7274 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▎▎▊▌▉▌▌▉▌▉▊▎▎ + ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ + ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ + ▊▉▏▌██ ▎▊▌▉▊▌▉▊ + ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ + ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ + ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ + ▎▍ ▍▌ █▏▊ ▏ ▏ + ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ + ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ + ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ + █▉ ▋▌▎ ▎▉█ ▋ + ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ + ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ + ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_22.txt b/codex-rs/tui2/frames/vbars/frame_22.txt new file mode 100644 index 0000000000..3b1ce4ecde --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▌▉▌▉▊▎ + ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ + ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ + ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ + ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ + ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ + ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ + ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ + ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ + ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ + ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ + ▍▉▍▎▍▊ ▎▉▊█▏▊█ + ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ + █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ + █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_23.txt b/codex-rs/tui2/frames/vbars/frame_23.txt new file mode 100644 index 0000000000..0b99396129 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▎▊▉▎▌▉▉▉▉▌▉▎▎ + ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ + ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ + ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ + ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ + ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ + ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ + ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ + ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ + ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ + ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ + ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ + ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ + ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ + █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_24.txt b/codex-rs/tui2/frames/vbars/frame_24.txt new file mode 100644 index 0000000000..5e26d7a27b --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▎▏▎▌▉▉▉▌▌▊▎ + ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ + ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ + ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ + ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ + ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ + ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ + ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ + ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ + ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ + ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ + ▍ ▍▊▉▍ ▏ █▋▌▋ + ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ + ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ + ▉▋▎▉▉▌▍▎▎▌▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_25.txt b/codex-rs/tui2/frames/vbars/frame_25.txt new file mode 100644 index 0000000000..5009b8b66d --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▎▉▌▉▌▉▉▉▉▎ + ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ + ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ + ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ + ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ + ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ + ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ + ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ + ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ + ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ + ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ + ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ + ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ + ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ + ▏▊█▉▍▉▊▎▉▏▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_26.txt b/codex-rs/tui2/frames/vbars/frame_26.txt new file mode 100644 index 0000000000..900a51c3b5 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▎▌▉▉▉█▉▊ + ▋▉▏▌▋▍▏▉▌▏▊ + ▋▉██▏▌▍▏▍██▍▊ + ▌▉█▊▏▏▏▎▏▏▎▉▋▍ + ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ + ▍ █▏▋▋▋█▉▎▍█▍▍▋ + ▏ ▉█▌▏▏▋▏▏▌▌█▉ + ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ + ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ + ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ + ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ + ▍▎▏▏▋▍ ▋▋█▏▏▎ + ▉ ▉▏▍ ▍▎▋▏▋▊ + ▏█▉▍▍█▉▎▉▉▌ + ▍▋▋▍▉▏▎▍▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_27.txt b/codex-rs/tui2/frames/vbars/frame_27.txt new file mode 100644 index 0000000000..0b2e8c7306 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▌▉▉▉▉▊ + ▏▌▋▋▏▋▍ + ▌█▎ ▉▏▋█▏ + ▏▏▏▍▏▏▌▏▏ + ▏ ▎▏▎▊█▌ + ▏▎▋▎█▎▏▏▏▎ + ▌ █▏▏▎▉▏▏ + ▏ ▎▏▏▏▌█▏▏ + ▏▋ ▋ ▏▉▏▏▏▏ + █▌ ▋█▎▏▎▉▏▏ + ▏▊ ▎▍▉▉▉▋█ + ▏▏▉▉▏▏▎▋▏ + ▏▌▌▉▌▊▋▏▍ + ▍▎▎▏▍▌▋▋ + █▍█ ▍▍▋ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_28.txt b/codex-rs/tui2/frames/vbars/frame_28.txt new file mode 100644 index 0000000000..01ce82b6d3 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▋▉▉▌ + ▏▉▎▏▎ + ▌ ▌▏▎ + ▏▊▎▉▏ + ▏ ▏▏ + ▏ ▊█▏ + ▍▏▏ ▎▏ + ▏▊██▏ + ▋▍ ▎▏ + ▏▏▋▋▏▏ + ▏ ▎▏ + ▏▌▉▌▏ + ▏▌▉▏▏ + ▉▎▎▋▏ + ▏ ▋▉▏ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_29.txt b/codex-rs/tui2/frames/vbars/frame_29.txt new file mode 100644 index 0000000000..c682a6082c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▊ + ▊▏▉▋ ▉▍ + ▍▊▏▏ ██▏ + ▏▏▏▏▍▍▊▋ + ▍ ▏█▏ ▏ + ▏▊▏▏▏ ▎▎ + ▏▍▍▏▏▏ ▍ + ▏▏▌▏▏ + ▏▏▏▉▏ ▎ + ▎▏▌▏▏ ▎ + ▉▏▏▋▏▍ ▌ + ▏▋▏▏▏ ▎▏ + ▏▏▏▏▎▏▎▏ + ▏▌▏▉▋█▏█ + ▏▏▉ ▋▊ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_3.txt b/codex-rs/tui2/frames/vbars/frame_3.txt new file mode 100644 index 0000000000..6c202bc0c3 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▉▌▌▌▌▊▎ + ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ + ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ + ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ + █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ + ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ + ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ + ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ + ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ + ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ + █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ + █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ + ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ + ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ + ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_30.txt b/codex-rs/tui2/frames/vbars/frame_30.txt new file mode 100644 index 0000000000..a44dbb6ed0 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▎▌█▉▉▉▊ + ▎▋▌▏▏▍▉█▌▍ + ██▍▏▊▍▍▏▉▊▍ + ▏▏▍▏▋▏▎▍▏▍ ▍▊ + ▌▉▏▏▎▍▏▊▏▊ ▏ + ▏▉▏▍▉ ▉▏▏▏▏▋▏ + ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ + █▏ █▉▌▏▊▏█▏▊ ▎ + █▏▎█▍▏▌▏▍▋▏▊ ▎ + █▏▏▍▏▎▎▏▏ ▉▊ ▎ + ▍▏▏▌▍▎▉▏▏▏▉▏▏ + ▏▉▋▋▊▋▉▍▏▏█ ▏ + ▍▏▍▏▏▋▉▏▏▊▏▋ + ▊▍▊▉▌▍▋▏▊▋█ + ▍▊▋▏▍▎▎▌█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_31.txt b/codex-rs/tui2/frames/vbars/frame_31.txt new file mode 100644 index 0000000000..70da8799e2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▎▋▋▉▉▉▉▌▊ + ▎▉▉▌▉▊▎█▉▍█▉▊ + ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ + ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ + ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ + ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ + ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ + ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ + ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ + █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ + ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ + ▏▏▏▍▍▍▊ ▎▌▉ ▏ + ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ + ▋▏▍▏▋▎▏▎▊▏█▊▏▎ + ██▌▏▎▉▉▋▋▌▉ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_32.txt b/codex-rs/tui2/frames/vbars/frame_32.txt new file mode 100644 index 0000000000..ddfb4be3fe --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▌▌▏▊▎ + ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ + ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ + █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ + ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ + ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ + ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ + ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ + ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ + ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ + ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ + ▍▊▏▍▍▍ ▊▍▏▋█▏ + █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ + ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ + ▏▌▌▎▎▊▉▋▉▎██ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_33.txt b/codex-rs/tui2/frames/vbars/frame_33.txt new file mode 100644 index 0000000000..7fa5ac29bc --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▉▌▏▋▎ + ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ + ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ + ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ + ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ + ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ + ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ + ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ + ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ + ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ + ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ + ▍▉▍▉▊█▊ ▋▋▌▋▌▋ + ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ + ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ + █▌▌▉▊▎▎▎▍▉▉▌▊█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_34.txt b/codex-rs/tui2/frames/vbars/frame_34.txt new file mode 100644 index 0000000000..a8c447ff18 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▎▊▌▉▉▉▉▌▏▊▎ + ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ + ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ + ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ + ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ + ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ + ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ + ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ + ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ + ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ + ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ + ▋▊▍▌▌▍▎ ▋▌▋▏▋█ + ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ + ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ + █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_35.txt b/codex-rs/tui2/frames/vbars/frame_35.txt new file mode 100644 index 0000000000..ba905231e1 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▌▉▉▉▌▉▊▎ + ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ + ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ + ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ + ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ + ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ + ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ + ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ + ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ + ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ + ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ + █▋▍▍▋▏ ▎▋▉ ▊▍ + ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ + █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ + █▍▏▌▌▎▎▋▎▎▉▉▉█▍ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_36.txt b/codex-rs/tui2/frames/vbars/frame_36.txt new file mode 100644 index 0000000000..246ed3d692 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▉▉▉▉▌▉▊▎ + ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ + ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ + ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ + ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ + ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ + ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ + ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ + ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ + ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ + █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ + █▊█▍▌▋ ▊▋▏█▋ + ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ + ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ + ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_4.txt b/codex-rs/tui2/frames/vbars/frame_4.txt new file mode 100644 index 0000000000..5dcae750bc --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▌▌▉▌▉▊▎ + ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ + ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ + ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ + ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ + ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ + █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ + ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ + ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ + ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ + ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ + ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ + ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ + ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ + ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_5.txt b/codex-rs/tui2/frames/vbars/frame_5.txt new file mode 100644 index 0000000000..cab16091cb --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▌▉▊▎ + ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ + ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ + ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ + ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ + ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ + ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ + ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ + ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ + ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ + ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ + ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ + ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ + ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ + ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_6.txt b/codex-rs/tui2/frames/vbars/frame_6.txt new file mode 100644 index 0000000000..e41e013ab0 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▉▉▎▎ + ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ + ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ + ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ + █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ + ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ + ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ + ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ + ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ + ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ + ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ + ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ + ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ + ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ + ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_7.txt b/codex-rs/tui2/frames/vbars/frame_7.txt new file mode 100644 index 0000000000..7a88d5ef14 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▎▋▏▌▉▉▌▌▉▊▎ + ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ + ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ + ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ + ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ + ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ + ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ + ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ + ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ + ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ + ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ + █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ + █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ + ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ + █▏▉█▏▋▏▎▎▊▎▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_8.txt b/codex-rs/tui2/frames/vbars/frame_8.txt new file mode 100644 index 0000000000..bbf2016fab --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▎▎▉▎▌▉▉▌▉▊▎ + ▊█▉▋▏▏▏▉▌▌ ▏ █▉ + ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ + ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ + █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ + ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ + ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ + ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ + ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ + ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ + ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ + █▊█▏▍▏▎▎ ▋▊▋▋▋█ + █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ + ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ + ▏▏▎▉▉▍▏▎▊▉▋▌▎ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_9.txt b/codex-rs/tui2/frames/vbars/frame_9.txt new file mode 100644 index 0000000000..4e36e6e126 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▋▌▍▉▋▉▉▌▊ + ▋▉▎▋▏▏▉█▌ ▎▊▍▎ + ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ + █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ + ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ + ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ + ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ + ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ + █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ + ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ + █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ + ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ + ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ + ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ + █▊▎ ▉▍▍▏▌▉▋█ + \ No newline at end of file diff --git a/codex-rs/tui2/prompt_for_init_command.md b/codex-rs/tui2/prompt_for_init_command.md new file mode 100644 index 0000000000..b8fd3886b3 --- /dev/null +++ b/codex-rs/tui2/prompt_for_init_command.md @@ -0,0 +1,40 @@ +Generate a file named AGENTS.md that serves as a contributor guide for this repository. +Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. +Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. + +Document Requirements + +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful (commands, directory paths, naming patterns). +- Maintain a professional, instructional tone. + +Recommended Sections + +Project Structure & Module Organization + +- Outline the project structure, including where the source code, tests, and assets are located. + +Build, Test, and Development Commands + +- List key commands for building, testing, and running locally (e.g., npm test, make build). +- Briefly explain what each command does. + +Coding Style & Naming Conventions + +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. + +Testing Guidelines + +- Identify testing frameworks and coverage requirements. +- State test naming conventions and how to run tests. + +Commit & Pull Request Guidelines + +- Summarize commit message conventions found in the project’s Git history. +- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). + +(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. diff --git a/codex-rs/tui2/src/additional_dirs.rs b/codex-rs/tui2/src/additional_dirs.rs new file mode 100644 index 0000000000..cc43f3294b --- /dev/null +++ b/codex-rs/tui2/src/additional_dirs.rs @@ -0,0 +1,71 @@ +use codex_core::protocol::SandboxPolicy; +use std::path::PathBuf; + +/// Returns a warning describing why `--add-dir` entries will be ignored for the +/// resolved sandbox policy. The caller is responsible for presenting the +/// warning to the user (for example, printing to stderr). +pub fn add_dir_warning_message( + additional_dirs: &[PathBuf], + sandbox_policy: &SandboxPolicy, +) -> Option { + if additional_dirs.is_empty() { + return None; + } + + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), + } +} + +fn format_warning(additional_dirs: &[PathBuf]) -> String { + let joined_paths = additional_dirs + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(", "); + format!( + "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ) +} + +#[cfg(test)] +mod tests { + use super::add_dir_warning_message; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn returns_none_for_workspace_write() { + let sandbox = SandboxPolicy::new_workspace_write_policy(); + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn returns_none_for_danger_full_access() { + let sandbox = SandboxPolicy::DangerFullAccess; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn warns_for_read_only() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; + let message = add_dir_warning_message(&dirs, &sandbox) + .expect("expected warning for read-only sandbox"); + assert_eq!( + message, + "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ); + } + + #[test] + fn returns_none_when_no_additional_dirs() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs: Vec = Vec::new(); + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } +} diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs new file mode 100644 index 0000000000..4d4970b572 --- /dev/null +++ b/codex-rs/tui2/src/app.rs @@ -0,0 +1,1510 @@ +use crate::app_backtrack::BacktrackState; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalRequest; +use crate::chatwidget::ChatWidget; +use crate::diff_render::DiffSummary; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::file_search::FileSearchManager; +use crate::history_cell::HistoryCell; +use crate::model_migration::ModelMigrationOutcome; +use crate::model_migration::migration_copy_for_config; +use crate::model_migration::run_model_migration_prompt; +use crate::pager_overlay::Overlay; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::Renderable; +use crate::resume_picker::ResumeSelection; +use crate::skill_error_prompt::SkillErrorPromptOutcome; +use crate::skill_error_prompt::run_skill_error_prompt; +use crate::tui; +use crate::tui::TuiEvent; +use crate::update_action::UpdateAction; +use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; +use codex_core::ConversationManager; +use codex_core::config::Config; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::features::Feature; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::EventMsg; +use codex_core::protocol::FinalOutput; +use codex_core::protocol::Op; +use codex_core::protocol::SessionSource; +use codex_core::protocol::TokenUsage; +use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; +use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; +use tokio::select; +use tokio::sync::mpsc::unbounded_channel; + +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; + +const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; + +#[derive(Debug, Clone)] +pub struct AppExitInfo { + pub token_usage: TokenUsage, + pub conversation_id: Option, + pub update_action: Option, +} + +impl From for codex_tui::AppExitInfo { + fn from(info: AppExitInfo) -> Self { + codex_tui::AppExitInfo { + token_usage: info.token_usage, + conversation_id: info.conversation_id, + update_action: info.update_action.map(Into::into), + } + } +} + +fn session_summary( + token_usage: TokenUsage, + conversation_id: Option, +) -> Option { + if token_usage.is_zero() { + return None; + } + + let usage_line = FinalOutput::from(token_usage).to_string(); + let resume_command = + conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); + Some(SessionSummary { + usage_line, + resume_command, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionSummary { + usage_line: String, + resume_command: Option, +} + +fn should_show_model_migration_prompt( + current_model: &str, + target_model: &str, + hide_prompt_flag: Option, + available_models: Vec, +) -> bool { + if target_model == current_model || hide_prompt_flag.unwrap_or(false) { + return false; + } + + available_models + .iter() + .filter(|preset| preset.upgrade.is_some()) + .any(|preset| preset.model == current_model) +} + +fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Option { + match migration_config_key { + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => { + config.notices.hide_gpt_5_1_codex_max_migration_prompt + } + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt, + _ => None, + } +} + +async fn handle_model_migration_prompt_if_needed( + tui: &mut tui::Tui, + config: &mut Config, + model: &str, + app_event_tx: &AppEventSender, + auth_mode: Option, + models_manager: Arc, +) -> Option { + let available_models = models_manager.list_models(config).await; + let upgrade = available_models + .iter() + .find(|preset| preset.model == model) + .and_then(|preset| preset.upgrade.as_ref()); + + if let Some(ModelUpgrade { + id: target_model, + reasoning_effort_mapping, + migration_config_key, + }) = upgrade + { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { + return None; + } + + let target_model = target_model.to_string(); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); + if !should_show_model_migration_prompt( + model, + &target_model, + hide_prompt_flag, + available_models.clone(), + ) { + return None; + } + + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); + match run_model_migration_prompt(tui, prompt_copy).await { + ModelMigrationOutcome::Accepted => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + config.model = Some(target_model.clone()); + + let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping + && let Some(reasoning_effort) = config.model_reasoning_effort + { + reasoning_effort_mapping + .get(&reasoning_effort) + .cloned() + .or(config.model_reasoning_effort) + } else { + config.model_reasoning_effort + }; + + config.model_reasoning_effort = mapped_effort; + + app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); + app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); + app_event_tx.send(AppEvent::PersistModelSelection { + model: target_model.clone(), + effort: mapped_effort, + }); + } + ModelMigrationOutcome::Rejected => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + } + ModelMigrationOutcome::Exit => { + return Some(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + } + } + + None +} + +pub(crate) struct App { + pub(crate) server: Arc, + pub(crate) app_event_tx: AppEventSender, + pub(crate) chat_widget: ChatWidget, + pub(crate) auth_manager: Arc, + /// Config is stored here so we can recreate ChatWidgets as needed. + pub(crate) config: Config, + pub(crate) current_model: String, + pub(crate) active_profile: Option, + + pub(crate) file_search: FileSearchManager, + + pub(crate) transcript_cells: Vec>, + + // Pager overlay state (Transcript or Static like Diff) + pub(crate) overlay: Option, + pub(crate) deferred_history_lines: Vec>, + has_emitted_history_lines: bool, + + pub(crate) enhanced_keys_supported: bool, + + /// Controls the animation thread that sends CommitTick events. + pub(crate) commit_anim_running: Arc, + + // Esc-backtracking state grouped + pub(crate) backtrack: crate::app_backtrack::BacktrackState, + pub(crate) feedback: codex_feedback::CodexFeedback, + /// Set when the user confirms an update; propagated on exit. + pub(crate) pending_update_action: Option, + + /// Ignore the next ShutdownComplete event when we're intentionally + /// stopping a conversation (e.g., before starting a new one). + suppress_shutdown_complete: bool, + + // One-shot suppression of the next world-writable scan after user confirmation. + skip_world_writable_scan_once: bool, + + pub(crate) skills: Option>, +} + +impl App { + async fn shutdown_current_conversation(&mut self) { + if let Some(conversation_id) = self.chat_widget.conversation_id() { + self.suppress_shutdown_complete = true; + self.chat_widget.submit_op(Op::Shutdown); + self.server.remove_conversation(&conversation_id).await; + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn run( + tui: &mut tui::Tui, + auth_manager: Arc, + mut config: Config, + active_profile: Option, + initial_prompt: Option, + initial_images: Vec, + resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, + is_first_run: bool, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(app_event_tx); + + let auth_mode = auth_manager.auth().map(|auth| auth.mode); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::Cli, + )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + model.as_str(), + &app_event_tx, + auth_mode, + conversation_manager.get_models_manager(), + ) + .await; + if let Some(exit_info) = exit_info { + return Ok(exit_info); + } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } + + let skills_outcome = load_skills(&config); + if !skills_outcome.errors.is_empty() { + match run_skill_error_prompt(tui, &skills_outcome.errors).await { + SkillErrorPromptOutcome::Exit => { + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + SkillErrorPromptOutcome::Continue => {} + } + } + + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + + let enhanced_keys_supported = tui.enhanced_keys_supported(); + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(model.as_str(), &config) + .await; + let mut chat_widget = match resume_selection { + ResumeSelection::StartFresh | ResumeSelection::Exit => { + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new(init, conversation_manager.clone()) + } + ResumeSelection::Resume(path) => { + let resumed = conversation_manager + .resume_conversation_from_rollout( + config.clone(), + path.clone(), + auth_manager.clone(), + ) + .await + .wrap_err_with(|| { + format!("Failed to resume session from {}", path.display()) + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ) + } + }; + + chat_widget.maybe_prompt_windows_sandbox_enable(); + + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + #[cfg(not(debug_assertions))] + let upgrade_version = crate::updates::get_upgrade_version(&config); + + let mut app = Self { + server: conversation_manager.clone(), + app_event_tx, + chat_widget, + auth_manager: auth_manager.clone(), + config, + current_model: model.clone(), + active_profile, + file_search, + enhanced_keys_supported, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: feedback.clone(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills, + }; + + // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. + #[cfg(target_os = "windows")] + { + let should_check = codex_core::get_platform_sandbox().is_some() + && matches!( + app.config.sandbox_policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly + ) + && !app + .config + .notices + .hide_world_writable_warning + .unwrap_or(false); + if should_check { + let cwd = app.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let tx = app.app_event_tx.clone(); + let logs_base_dir = app.config.codex_home.clone(); + let sandbox_policy = app.config.sandbox_policy.clone(); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + } + } + + #[cfg(not(debug_assertions))] + if let Some(latest_version) = upgrade_version { + app.handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + ) + .await?; + } + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + tui.frame_requester().schedule_frame(); + + while select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event).await? + } + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + } {} + tui.terminal.clear()?; + Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, + }) + } + + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + if self.overlay.is_some() { + let _ = self.handle_backtrack_overlay_event(tui, event).await?; + } else { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; + } + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); + if self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + { + return Ok(true); + } + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + self.chat_widget.render(frame.area(), frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + } + } + Ok(true) + } + + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(self.current_model.as_str(), &self.config) + .await; + match event { + AppEvent::NewSession => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); + if let Some(summary) = summary { + let mut lines: Vec> = vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_from_rollout( + self.config.clone(), + path.clone(), + self.auth_manager.clone(), + ) + .await + { + Ok(resumed) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + self.current_model = model_family.get_model_slug().to_string(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } + AppEvent::InsertHistoryCell(cell) => { + let cell: Arc = cell.into(); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if !display.is_empty() { + // Only insert a separating blank line for new cells that are not + // part of an ongoing stream. Streaming continuations should not + // accrue extra blank lines between chunks. + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + AppEvent::StartCommitAnimation => { + if self + .commit_anim_running + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let tx = self.app_event_tx.clone(); + let running = self.commit_anim_running.clone(); + thread::spawn(move || { + while running.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(50)); + tx.send(AppEvent::CommitTick); + } + }); + } + } + AppEvent::StopCommitAnimation => { + self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::CommitTick => { + self.chat_widget.on_commit_tick(); + } + AppEvent::CodexEvent(event) => { + if self.suppress_shutdown_complete + && matches!(event.msg, EventMsg::ShutdownComplete) + { + self.suppress_shutdown_complete = false; + return Ok(true); + } + self.chat_widget.handle_codex_event(event); + } + AppEvent::ConversationHistory(ev) => { + self.on_conversation_history_for_backtrack(tui, ev).await?; + } + AppEvent::ExitRequest => { + return Ok(false); + } + AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::DiffResult(text) => { + // Clear the in-progress state in the bottom pane + self.chat_widget.on_diff_complete(); + // Enter alternate screen using TUI helper and build pager lines + let _ = tui.enter_alt_screen(); + let pager_lines: Vec> = if text.trim().is_empty() { + vec!["No changes detected.".italic().into()] + } else { + text.lines().map(ansi_escape_line).collect() + }; + self.overlay = Some(Overlay::new_static_with_lines( + pager_lines, + "D I F F".to_string(), + )); + tui.frame_requester().schedule_frame(); + } + AppEvent::StartFileSearch(query) => { + if !query.is_empty() { + self.file_search.on_user_query(query); + } + } + AppEvent::FileSearchResult { query, matches } => { + self.chat_widget.apply_file_search_result(query, matches); + } + AppEvent::RateLimitSnapshotFetched(snapshot) => { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + AppEvent::UpdateReasoningEffort(effort) => { + self.on_update_reasoning_effort(effort); + } + AppEvent::UpdateModel(model) => { + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); + self.current_model = model; + } + AppEvent::OpenReasoningPopup { model } => { + self.chat_widget.open_reasoning_popup(model); + } + AppEvent::OpenAllModelsPopup { models } => { + self.chat_widget.open_all_models_popup(models); + } + AppEvent::OpenFullAccessConfirmation { preset } => { + self.chat_widget.open_full_access_confirmation(preset); + } + AppEvent::OpenWorldWritableWarningConfirmation { + preset, + sample_paths, + extra_count, + failed_scan, + } => { + self.chat_widget.open_world_writable_warning_confirmation( + preset, + sample_paths, + extra_count, + failed_scan, + ); + } + AppEvent::OpenFeedbackNote { + category, + include_logs, + } => { + self.chat_widget.open_feedback_note(category, include_logs); + } + AppEvent::OpenFeedbackConsent { category } => { + self.chat_widget.open_feedback_consent(category); + } + AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { + self.chat_widget.open_windows_sandbox_enable_prompt(preset); + } + AppEvent::EnableWindowsSandboxForAgentMode { preset } => { + #[cfg(target_os = "windows")] + { + let profile = self.active_profile.as_deref(); + let feature_key = Feature::WindowsSandbox.key(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_feature_enabled(feature_key, true) + .apply() + .await + { + Ok(()) => { + self.config.set_windows_sandbox_globally(true); + self.chat_widget.clear_forced_auto_mode_downgrade(); + if let Some((sample_paths, extra_count, failed_scan)) = + self.chat_widget.world_writable_warning_details() + { + self.app_event_tx.send( + AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset.clone()), + sample_paths, + extra_count, + failed_scan, + }, + ); + } else { + self.app_event_tx.send(AppEvent::CodexOp( + Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(preset.approval), + sandbox_policy: Some(preset.sandbox.clone()), + model: None, + effort: None, + summary: None, + }, + )); + self.app_event_tx + .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); + self.app_event_tx + .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); + self.chat_widget.add_info_message( + "Enabled experimental Windows sandbox.".to_string(), + None, + ); + } + } + Err(err) => { + tracing::error!( + error = %err, + "failed to enable Windows sandbox feature" + ); + self.chat_widget.add_error_message(format!( + "Failed to enable the Windows sandbox feature: {err}" + )); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = preset; + } + } + AppEvent::PersistModelSelection { model, effort } => { + let profile = self.active_profile.as_deref(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_model(Some(model.as_str()), effort) + .apply() + .await + { + Ok(()) => { + let mut message = format!("Model changed to {model}"); + if let Some(label) = Self::reasoning_label_for(&model, effort) { + message.push(' '); + message.push_str(label); + } + if let Some(profile) = profile { + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); + } + self.chat_widget.add_info_message(message, None); + } + Err(err) => { + tracing::error!( + error = %err, + "failed to persist model selection" + ); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save model for profile `{profile}`: {err}" + )); + } else { + self.chat_widget + .add_error_message(format!("Failed to save default model: {err}")); + } + } + } + } + AppEvent::UpdateAskForApprovalPolicy(policy) => { + self.chat_widget.set_approval_policy(policy); + } + AppEvent::UpdateSandboxPolicy(policy) => { + #[cfg(target_os = "windows")] + let policy_is_workspace_write_or_ro = matches!( + policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly + ); + + self.config.sandbox_policy = policy.clone(); + #[cfg(target_os = "windows")] + if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly) + || codex_core::get_platform_sandbox().is_some() + { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + self.chat_widget.set_sandbox_policy(policy); + + // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. + #[cfg(target_os = "windows")] + { + // One-shot suppression if the user just confirmed continue. + if self.skip_world_writable_scan_once { + self.skip_world_writable_scan_once = false; + return Ok(true); + } + + let should_check = codex_core::get_platform_sandbox().is_some() + && policy_is_workspace_write_or_ro + && !self.chat_widget.world_writable_warning_hidden(); + if should_check { + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let tx = self.app_event_tx.clone(); + let logs_base_dir = self.config.codex_home.clone(); + let sandbox_policy = self.config.sandbox_policy.clone(); + Self::spawn_world_writable_scan( + cwd, + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); + } + } + } + AppEvent::SkipNextWorldWritableScan => { + self.skip_world_writable_scan_once = true; + } + AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { + self.chat_widget.set_full_access_warning_acknowledged(ack); + } + AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { + self.chat_widget + .set_world_writable_warning_acknowledged(ack); + } + AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { + self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); + } + AppEvent::PersistFullAccessWarningAcknowledged => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_full_access_warning(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist full access warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save full access confirmation preference: {err}" + )); + } + } + AppEvent::PersistWorldWritableWarningAcknowledged => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_world_writable_warning(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist world-writable warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save Agent mode warning preference: {err}" + )); + } + } + AppEvent::PersistRateLimitSwitchPromptHidden => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_rate_limit_model_nudge(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist rate limit switch prompt preference" + ); + self.chat_widget.add_error_message(format!( + "Failed to save rate limit reminder preference: {err}" + )); + } + } + AppEvent::PersistModelMigrationPromptAcknowledged { migration_config } => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_model_migration_prompt(&migration_config, true) + .apply() + .await + { + tracing::error!(error = %err, "failed to persist model migration prompt acknowledgement"); + self.chat_widget.add_error_message(format!( + "Failed to save model migration prompt preference: {err}" + )); + } + } + AppEvent::OpenApprovalsPopup => { + self.chat_widget.open_approvals_popup(); + } + AppEvent::OpenReviewBranchPicker(cwd) => { + self.chat_widget.show_review_branch_picker(&cwd).await; + } + AppEvent::OpenReviewCommitPicker(cwd) => { + self.chat_widget.show_review_commit_picker(&cwd).await; + } + AppEvent::OpenReviewCustomPrompt => { + self.chat_widget.show_review_custom_prompt(); + } + AppEvent::FullScreenApprovalRequest(request) => match request { + ApprovalRequest::ApplyPatch { cwd, changes, .. } => { + let _ = tui.enter_alt_screen(); + let diff_summary = DiffSummary::new(changes, cwd); + self.overlay = Some(Overlay::new_static_with_renderables( + vec![diff_summary.into()], + "P A T C H".to_string(), + )); + } + ApprovalRequest::Exec { command, .. } => { + let _ = tui.enter_alt_screen(); + let full_cmd = strip_bash_lc_and_escape(&command); + let full_cmd_lines = highlight_bash_to_lines(&full_cmd); + self.overlay = Some(Overlay::new_static_with_lines( + full_cmd_lines, + "E X E C".to_string(), + )); + } + ApprovalRequest::McpElicitation { + server_name, + message, + .. + } => { + let _ = tui.enter_alt_screen(); + let paragraph = Paragraph::new(vec![ + Line::from(vec!["Server: ".into(), server_name.bold()]), + Line::from(""), + Line::from(message), + ]) + .wrap(Wrap { trim: false }); + self.overlay = Some(Overlay::new_static_with_renderables( + vec![Box::new(paragraph)], + "E L I C I T A T I O N".to_string(), + )); + } + }, + } + Ok(true) + } + + fn reasoning_label(reasoning_effort: Option) -> &'static str { + match reasoning_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } + + fn reasoning_label_for( + model: &str, + reasoning_effort: Option, + ) -> Option<&'static str> { + (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) + } + + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { + self.chat_widget.token_usage() + } + + fn on_update_reasoning_effort(&mut self, effort: Option) { + self.chat_widget.set_reasoning_effort(effort); + self.config.model_reasoning_effort = effort; + } + + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Enter alternate screen and set viewport to full size. + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + tui.frame_requester().schedule_frame(); + } + // Esc primes/advances backtracking only in normal (not working) mode + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + if self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + { + self.handle_backtrack_esc_key(tui); + } else { + self.chat_widget.handle_key_event(key_event); + } + } + // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. + KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + } if self.backtrack.primed + && self.backtrack.nth_user_message != usize::MAX + && self.chat_widget.composer_is_empty() => + { + // Delegate to helper for clarity; preserves behavior. + self.confirm_backtrack_from_main(); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + // Any non-Esc key press should cancel a primed backtrack. + // This avoids stale "Esc-primed" state after the user starts typing + // (even if they later backspace to empty). + if key_event.code != KeyCode::Esc && self.backtrack.primed { + self.reset_backtrack_state(); + } + self.chat_widget.handle_key_event(key_event); + } + _ => { + // Ignore Release key events. + } + }; + } + + #[cfg(target_os = "windows")] + fn spawn_world_writable_scan( + cwd: PathBuf, + env_map: std::collections::HashMap, + logs_base_dir: PathBuf, + sandbox_policy: codex_core::protocol::SandboxPolicy, + tx: AppEventSender, + ) { + tokio::task::spawn_blocking(move || { + let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( + &logs_base_dir, + &cwd, + &env_map, + &sandbox_policy, + Some(logs_base_dir.as_path()), + ); + if result.is_err() { + // Scan failed: warn without examples. + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: None, + sample_paths: Vec::new(), + extra_count: 0usize, + failed_scan: true, + }); + } + }); + } +} + +fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> { + match migration_config_key { + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES), + _ => None, + } +} + +fn migration_prompt_allows_auth_mode( + auth_mode: Option, + migration_config_key: &str, +) -> bool { + if let Some(allowed_modes) = migration_prompt_allowed_auth_modes(migration_config_key) { + match auth_mode { + None => true, + Some(mode) => allowed_modes.contains(&mode), + } + } else { + auth_mode != Some(AuthMode::ApiKey) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_backtrack::BacktrackState; + use crate::app_backtrack::user_count; + use crate::chatwidget::tests::make_chatwidget_manual_with_sender; + use crate::file_search::FileSearchManager; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use crate::history_cell::UserHistoryCell; + use crate::history_cell::new_session_info; + use codex_core::AuthManager; + use codex_core::CodexAuth; + use codex_core::ConversationManager; + use codex_core::protocol::AskForApproval; + use codex_core::protocol::Event; + use codex_core::protocol::EventMsg; + use codex_core::protocol::SandboxPolicy; + use codex_core::protocol::SessionConfiguredEvent; + use codex_protocol::ConversationId; + use ratatui::prelude::Line; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + App { + server, + app_event_tx, + chat_widget, + auth_manager, + config, + current_model, + active_profile: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + } + } + + fn make_test_app_with_channels() -> ( + App, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + )); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + ( + App { + server, + app_event_tx, + chat_widget, + auth_manager, + config, + current_model, + active_profile: None, + file_search, + transcript_cells: Vec::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + }, + rx, + op_rx, + ) + } + + fn all_model_presets() -> Vec { + codex_core::openai_models::model_presets::all_model_presets().clone() + } + + #[test] + fn model_migration_prompt_only_shows_for_deprecated_models() { + assert!(should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex-mini", + "gpt-5.1-codex-mini", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex-max", + None, + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + } + + #[test] + fn model_migration_prompt_respects_hide_flag_and_self_target() { + assert!(!should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + Some(true), + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1", + "gpt-5.1", + None, + all_model_presets() + )); + } + + #[test] + fn update_reasoning_effort_updates_config() { + let mut app = make_test_app(); + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.chat_widget.config_ref().model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + } + + #[test] + fn backtrack_selection_with_duplicate_history_targets_unique_turn() { + let mut app = make_test_app(); + + let user_cell = |text: &str| -> Arc { + Arc::new(UserHistoryCell { + message: text.to_string(), + }) as Arc + }; + let agent_cell = |text: &str| -> Arc { + Arc::new(AgentMessageCell::new( + vec![Line::from(text.to_string())], + true, + )) as Arc + }; + + let make_header = |is_first| { + let event = SessionConfiguredEvent { + session_id: ConversationId::new(), + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + skill_load_outcome: None, + rollout_path: PathBuf::new(), + }; + Arc::new(new_session_info( + app.chat_widget.config_ref(), + app.current_model.as_str(), + event, + is_first, + )) as Arc + }; + + // Simulate the transcript after trimming for a fork, replaying history, and + // appending the edited turn. The session header separates the retained history + // from the forked conversation's replayed turns. + app.transcript_cells = vec![ + make_header(true), + user_cell("first question"), + agent_cell("answer first"), + user_cell("follow-up"), + agent_cell("answer follow-up"), + make_header(false), + user_cell("first question"), + agent_cell("answer first"), + user_cell("follow-up (edited)"), + agent_cell("answer edited"), + ]; + + assert_eq!(user_count(&app.transcript_cells), 2); + + app.backtrack.base_id = Some(ConversationId::new()); + app.backtrack.primed = true; + app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); + + app.confirm_backtrack_from_main(); + + let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); + assert_eq!(nth, 1); + assert_eq!(prefill, "follow-up (edited)"); + } + + #[tokio::test] + async fn new_session_requests_shutdown_for_previous_conversation() { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels(); + + let conversation_id = ConversationId::new(); + let event = SessionConfiguredEvent { + session_id: conversation_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + skill_load_outcome: None, + rollout_path: PathBuf::new(), + }; + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(event), + }); + + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + app.shutdown_current_conversation().await; + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), + Err(_) => panic!("expected shutdown op to be sent"), + } + } + + #[test] + fn session_summary_skip_zero_usage() { + assert!(session_summary(TokenUsage::default(), None).is_none()); + } + + #[test] + fn session_summary_includes_resume_hint() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = + ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + let summary = session_summary(usage, Some(conversation)).expect("summary"); + assert_eq!( + summary.usage_line, + "Token usage: total=12 input=10 output=2" + ); + assert_eq!( + summary.resume_command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn gpt5_migration_allows_api_key_and_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn gpt_5_1_codex_max_migration_limits_to_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn other_migrations_block_api_key() { + assert!(!migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + "unknown" + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + "unknown" + )); + } +} diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs new file mode 100644 index 0000000000..deb629765a --- /dev/null +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -0,0 +1,518 @@ +use std::any::TypeId; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::app::App; +use crate::history_cell::SessionInfoCell; +use crate::history_cell::UserHistoryCell; +use crate::pager_overlay::Overlay; +use crate::tui; +use crate::tui::TuiEvent; +use codex_core::protocol::ConversationPathResponseEvent; +use codex_protocol::ConversationId; +use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; + +/// Aggregates all backtrack-related state used by the App. +#[derive(Default)] +pub(crate) struct BacktrackState { + /// True when Esc has primed backtrack mode in the main view. + pub(crate) primed: bool, + /// Session id of the base conversation to fork from. + pub(crate) base_id: Option, + /// Index in the transcript of the last user message. + pub(crate) nth_user_message: usize, + /// True when the transcript overlay is showing a backtrack preview. + pub(crate) overlay_preview_active: bool, + /// Pending fork request: (base_id, nth_user_message, prefill). + pub(crate) pending: Option<(ConversationId, usize, String)>, +} + +impl App { + /// Route overlay events when transcript overlay is active. + /// - If backtrack preview is active: Esc steps selection; Enter confirms. + /// - Otherwise: Esc begins preview; all other events forward to overlay. + /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + pub(crate) async fn handle_backtrack_overlay_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + if self.backtrack.overlay_preview_active { + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => { + self.overlay_step_backtrack(tui, event)?; + Ok(true) + } + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + }) => { + self.overlay_confirm_backtrack(tui); + Ok(true) + } + // Catchall: forward any other events to the overlay widget. + _ => { + self.overlay_forward_event(tui, event)?; + Ok(true) + } + } + } else if let TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) = event + { + // First Esc in transcript overlay: begin backtrack preview at latest user message. + self.begin_overlay_backtrack_preview(tui); + Ok(true) + } else { + // Not in backtrack mode: forward events to the overlay widget. + self.overlay_forward_event(tui, event)?; + Ok(true) + } + } + + /// Handle global Esc presses for backtracking when no overlay is present. + pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { + if !self.chat_widget.composer_is_empty() { + return; + } + + if !self.backtrack.primed { + self.prime_backtrack(); + } else if self.overlay.is_none() { + self.open_backtrack_preview(tui); + } else if self.backtrack.overlay_preview_active { + self.step_backtrack_and_highlight(tui); + } + } + + /// Stage a backtrack and request conversation history from the agent. + pub(crate) fn request_backtrack( + &mut self, + prefill: String, + base_id: ConversationId, + nth_user_message: usize, + ) { + self.backtrack.pending = Some((base_id, nth_user_message, prefill)); + if let Some(path) = self.chat_widget.rollout_path() { + let ev = ConversationPathResponseEvent { + conversation_id: base_id, + path, + }; + self.app_event_tx + .send(crate::app_event::AppEvent::ConversationHistory(ev)); + } else { + tracing::error!("rollout path unavailable; cannot backtrack"); + } + } + + /// Open transcript overlay (enters alternate screen and shows full transcript). + pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + tui.frame_requester().schedule_frame(); + } + + /// Close transcript overlay and restore normal UI. + pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { + let _ = tui.leave_alt_screen(); + let was_backtrack = self.backtrack.overlay_preview_active; + if !self.deferred_history_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + tui.insert_history_lines(lines); + } + self.overlay = None; + self.backtrack.overlay_preview_active = false; + if was_backtrack { + // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). + self.reset_backtrack_state(); + } + } + + /// Re-render the full transcript into the terminal scrollback in one call. + /// Useful when switching sessions to ensure prior history remains visible. + pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { + if !self.transcript_cells.is_empty() { + let width = tui.terminal.last_known_screen_size.width; + for cell in &self.transcript_cells { + tui.insert_history_lines(cell.display_lines(width)); + } + } + } + + /// Initialize backtrack state and show composer hint. + fn prime_backtrack(&mut self) { + self.backtrack.primed = true; + self.backtrack.nth_user_message = usize::MAX; + self.backtrack.base_id = self.chat_widget.conversation_id(); + self.chat_widget.show_esc_backtrack_hint(); + } + + /// Open overlay and begin backtrack preview flow (first step + highlight). + fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) { + self.open_transcript_overlay(tui); + self.backtrack.overlay_preview_active = true; + // Composer is hidden by overlay; clear its hint. + self.chat_widget.clear_esc_backtrack_hint(); + self.step_backtrack_and_highlight(tui); + } + + /// When overlay is already open, begin preview mode and select latest user message. + fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) { + self.backtrack.primed = true; + self.backtrack.base_id = self.chat_widget.conversation_id(); + self.backtrack.overlay_preview_active = true; + let count = user_count(&self.transcript_cells); + if let Some(last) = count.checked_sub(1) { + self.apply_backtrack_selection(last); + } + tui.frame_requester().schedule_frame(); + } + + /// Step selection to the next older user message and update overlay. + fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { + let count = user_count(&self.transcript_cells); + if count == 0 { + return; + } + + let last_index = count.saturating_sub(1); + let next_selection = if self.backtrack.nth_user_message == usize::MAX { + last_index + } else if self.backtrack.nth_user_message == 0 { + 0 + } else { + self.backtrack + .nth_user_message + .saturating_sub(1) + .min(last_index) + }; + + self.apply_backtrack_selection(next_selection); + tui.frame_requester().schedule_frame(); + } + + /// Apply a computed backtrack selection to the overlay and internal counter. + fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { + self.backtrack.nth_user_message = nth_user_message; + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.set_highlight_cell(Some(cell_idx)); + } + } else { + self.backtrack.nth_user_message = usize::MAX; + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.set_highlight_cell(None); + } + } + } + + /// Forward any event to the overlay and close it if done. + fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if let Some(overlay) = &mut self.overlay { + overlay.handle_event(tui, event)?; + if overlay.is_done() { + self.close_transcript_overlay(tui); + tui.frame_requester().schedule_frame(); + } + } + Ok(()) + } + + /// Handle Enter in overlay backtrack preview: confirm selection and reset state. + fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { + let nth_user_message = self.backtrack.nth_user_message; + if let Some(base_id) = self.backtrack.base_id { + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + self.close_transcript_overlay(tui); + self.request_backtrack(prefill, base_id, nth_user_message); + } + self.reset_backtrack_state(); + } + + /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. + fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if self.backtrack.base_id.is_some() { + self.step_backtrack_and_highlight(tui); + } else { + self.overlay_forward_event(tui, event)?; + } + Ok(()) + } + + /// Confirm a primed backtrack from the main view (no overlay visible). + /// Computes the prefill from the selected user message and requests history. + pub(crate) fn confirm_backtrack_from_main(&mut self) { + if let Some(base_id) = self.backtrack.base_id { + let prefill = + nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); + } + self.reset_backtrack_state(); + } + + /// Clear all backtrack-related state and composer hints. + pub(crate) fn reset_backtrack_state(&mut self) { + self.backtrack.primed = false; + self.backtrack.base_id = None; + self.backtrack.nth_user_message = usize::MAX; + // In case a hint is somehow still visible (e.g., race with overlay open/close). + self.chat_widget.clear_esc_backtrack_hint(); + } + + /// Handle a ConversationHistory response while a backtrack is pending. + /// If it matches the primed base session, fork and switch to the new conversation. + pub(crate) async fn on_conversation_history_for_backtrack( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + ) -> Result<()> { + if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() + && ev.conversation_id == *base_id + && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() + { + self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) + .await; + } + Ok(()) + } + + /// Fork the conversation using provided history and switch UI/state accordingly. + async fn fork_and_switch_to_new_conversation( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + nth_user_message: usize, + prefill: String, + ) { + let cfg = self.chat_widget.config_ref().clone(); + // Perform the fork via a thin wrapper for clarity/testability. + let result = self + .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) + .await; + match result { + Ok(new_conv) => { + self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) + } + Err(e) => tracing::error!("error forking conversation: {e:#}"), + } + } + + /// Thin wrapper around ConversationManager::fork_conversation. + async fn perform_fork( + &self, + path: PathBuf, + nth_user_message: usize, + cfg: codex_core::config::Config, + ) -> codex_core::error::Result { + self.server + .fork_conversation(nth_user_message, cfg, path) + .await + } + + /// Install a forked conversation into the ChatWidget and update UI to reflect selection. + fn install_forked_conversation( + &mut self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + new_conv: codex_core::NewConversation, + nth_user_message: usize, + prefill: &str, + ) { + let conv = new_conv.conversation; + let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); + let init = crate::chatwidget::ChatWidgetInit { + config: cfg, + model_family: model_family.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + }; + self.chat_widget = + crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); + // Trim transcript up to the selected user message and re-render it. + self.trim_transcript_for_backtrack(nth_user_message); + self.render_transcript_once(tui); + if !prefill.is_empty() { + self.chat_widget.set_composer_text(prefill.to_string()); + } + tui.frame_requester().schedule_frame(); + } + + /// Trim transcript_cells to preserve only content up to the selected user message. + fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { + trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); + } +} + +fn trim_transcript_cells_to_nth_user( + transcript_cells: &mut Vec>, + nth_user_message: usize, +) { + if nth_user_message == usize::MAX { + return; + } + + if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { + transcript_cells.truncate(cut_idx); + } +} + +pub(crate) fn user_count(cells: &[Arc]) -> usize { + user_positions_iter(cells).count() +} + +fn nth_user_position( + cells: &[Arc], + nth: usize, +) -> Option { + user_positions_iter(cells) + .enumerate() + .find_map(|(i, idx)| (i == nth).then_some(idx)) +} + +fn user_positions_iter( + cells: &[Arc], +) -> impl Iterator + '_ { + let session_start_type = TypeId::of::(); + let user_type = TypeId::of::(); + let type_of = |cell: &Arc| cell.as_any().type_id(); + + let start = cells + .iter() + .rposition(|cell| type_of(cell) == session_start_type) + .map_or(0, |idx| idx + 1); + + cells + .iter() + .enumerate() + .skip(start) + .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use ratatui::prelude::Line; + use std::sync::Arc; + + #[test] + fn trim_transcript_for_first_user_drops_user_and_newer_cells() { + let mut cells: Vec> = vec![ + Arc::new(UserHistoryCell { + message: "first user".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) + as Arc, + ]; + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert!(cells.is_empty()); + } + + #[test] + fn trim_transcript_preserves_cells_before_selected_user() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) + as Arc, + ]; + trim_transcript_cells_to_nth_user(&mut cells, 0); + + assert_eq!(cells.len(), 1); + let agent = cells[0] + .as_any() + .downcast_ref::() + .expect("agent cell"); + let agent_lines = agent.display_lines(u16::MAX); + assert_eq!(agent_lines.len(), 1); + let intro_text: String = agent_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "• intro"); + } + + #[test] + fn trim_transcript_for_later_user_keeps_prior_history() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) + as Arc, + Arc::new(UserHistoryCell { + message: "first".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) + as Arc, + Arc::new(UserHistoryCell { + message: "second".to_string(), + }) as Arc, + Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) + as Arc, + ]; + trim_transcript_cells_to_nth_user(&mut cells, 1); + + assert_eq!(cells.len(), 3); + let agent_intro = cells[0] + .as_any() + .downcast_ref::() + .expect("intro agent"); + let intro_lines = agent_intro.display_lines(u16::MAX); + let intro_text: String = intro_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(intro_text, "• intro"); + + let user_first = cells[1] + .as_any() + .downcast_ref::() + .expect("first user"); + assert_eq!(user_first.message, "first"); + + let agent_between = cells[2] + .as_any() + .downcast_ref::() + .expect("between agent"); + let between_lines = agent_between.display_lines(u16::MAX); + let between_text: String = between_lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(between_text, " between"); + } +} diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs new file mode 100644 index 0000000000..c92dab4b3a --- /dev/null +++ b/codex-rs/tui2/src/app_event.rs @@ -0,0 +1,185 @@ +use std::path::PathBuf; + +use codex_common::approval_presets::ApprovalPreset; +use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Event; +use codex_core::protocol::RateLimitSnapshot; +use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; + +use crate::bottom_pane::ApprovalRequest; +use crate::history_cell::HistoryCell; + +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::openai_models::ReasoningEffort; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub(crate) enum AppEvent { + CodexEvent(Event), + + /// Start a new session. + NewSession, + + /// Open the resume picker inside the running TUI session. + OpenResumePicker, + + /// Request to exit the application gracefully. + ExitRequest, + + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids + /// bubbling channels through layers of widgets. + CodexOp(codex_core::protocol::Op), + + /// Kick off an asynchronous file search for the given query (text after + /// the `@`). Previous searches may be cancelled by the app layer so there + /// is at most one in-flight search. + StartFileSearch(String), + + /// Result of a completed asynchronous file search. The `query` echoes the + /// original search term so the UI can decide whether the results are + /// still relevant. + FileSearchResult { + query: String, + matches: Vec, + }, + + /// Result of refreshing rate limits + RateLimitSnapshotFetched(RateLimitSnapshot), + + /// Result of computing a `/diff` command. + DiffResult(String), + + InsertHistoryCell(Box), + + StartCommitAnimation, + StopCommitAnimation, + CommitTick, + + /// Update the current reasoning effort in the running app and widget. + UpdateReasoningEffort(Option), + + /// Update the current model slug in the running app and widget. + UpdateModel(String), + + /// Persist the selected model and reasoning effort to the appropriate config. + PersistModelSelection { + model: String, + effort: Option, + }, + + /// Open the reasoning selection popup after picking a model. + OpenReasoningPopup { + model: ModelPreset, + }, + + /// Open the full model picker (non-auto models). + OpenAllModelsPopup { + models: Vec, + }, + + /// Open the confirmation prompt before enabling full access mode. + OpenFullAccessConfirmation { + preset: ApprovalPreset, + }, + + /// Open the Windows world-writable directories warning. + /// If `preset` is `Some`, the confirmation will apply the provided + /// approval/sandbox configuration on Continue; if `None`, it performs no + /// policy change and only acknowledges/dismisses the warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWorldWritableWarningConfirmation { + preset: Option, + /// Up to 3 sample world-writable directories to display in the warning. + sample_paths: Vec, + /// If there are more than `sample_paths`, this carries the remaining count. + extra_count: usize, + /// True when the scan failed (e.g. ACL query error) and protections could not be verified. + failed_scan: bool, + }, + + /// Prompt to enable the Windows sandbox feature before using Agent mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWindowsSandboxEnablePrompt { + preset: ApprovalPreset, + }, + + /// Enable the Windows sandbox feature and switch to Agent mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + EnableWindowsSandboxForAgentMode { + preset: ApprovalPreset, + }, + + /// Update the current approval policy in the running app and widget. + UpdateAskForApprovalPolicy(AskForApproval), + + /// Update the current sandbox policy in the running app and widget. + UpdateSandboxPolicy(SandboxPolicy), + + /// Update whether the full access warning prompt has been acknowledged. + UpdateFullAccessWarningAcknowledged(bool), + + /// Update whether the world-writable directories warning has been acknowledged. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + UpdateWorldWritableWarningAcknowledged(bool), + + /// Update whether the rate limit switch prompt has been acknowledged for the session. + UpdateRateLimitSwitchPromptHidden(bool), + + /// Persist the acknowledgement flag for the full access warning prompt. + PersistFullAccessWarningAcknowledged, + + /// Persist the acknowledgement flag for the world-writable directories warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + PersistWorldWritableWarningAcknowledged, + + /// Persist the acknowledgement flag for the rate limit switch prompt. + PersistRateLimitSwitchPromptHidden, + + /// Persist the acknowledgement flag for the model migration prompt. + PersistModelMigrationPromptAcknowledged { + migration_config: String, + }, + + /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + SkipNextWorldWritableScan, + + /// Re-open the approval presets popup. + OpenApprovalsPopup, + + /// Forwarded conversation history snapshot from the current conversation. + ConversationHistory(ConversationPathResponseEvent), + + /// Open the branch picker option from the review popup. + OpenReviewBranchPicker(PathBuf), + + /// Open the commit picker option from the review popup. + OpenReviewCommitPicker(PathBuf), + + /// Open the custom prompt option from the review popup. + OpenReviewCustomPrompt, + + /// Open the approval popup. + FullScreenApprovalRequest(ApprovalRequest), + + /// Open the feedback note entry overlay after the user selects a category. + OpenFeedbackNote { + category: FeedbackCategory, + include_logs: bool, + }, + + /// Open the upload consent popup for feedback after selecting a category. + OpenFeedbackConsent { + category: FeedbackCategory, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FeedbackCategory { + BadResult, + GoodResult, + Bug, + Other, +} diff --git a/codex-rs/tui2/src/app_event_sender.rs b/codex-rs/tui2/src/app_event_sender.rs new file mode 100644 index 0000000000..c1427b3ff0 --- /dev/null +++ b/codex-rs/tui2/src/app_event_sender.rs @@ -0,0 +1,28 @@ +use tokio::sync::mpsc::UnboundedSender; + +use crate::app_event::AppEvent; +use crate::session_log; + +#[derive(Clone, Debug)] +pub(crate) struct AppEventSender { + pub app_event_tx: UnboundedSender, +} + +impl AppEventSender { + pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { + Self { app_event_tx } + } + + /// Send an event to the app event channel. If it fails, we swallow the + /// error and log it. + pub(crate) fn send(&self, event: AppEvent) { + // Record inbound events for high-fidelity session replay. + // Avoid double-logging Ops; those are logged at the point of submission. + if !matches!(event, AppEvent::CodexOp(_)) { + session_log::log_inbound_app_event(&event); + } + if let Err(e) = self.app_event_tx.send(event) { + tracing::error!("failed to send event: {e}"); + } + } +} diff --git a/codex-rs/tui2/src/ascii_animation.rs b/codex-rs/tui2/src/ascii_animation.rs new file mode 100644 index 0000000000..b2d9fc1d19 --- /dev/null +++ b/codex-rs/tui2/src/ascii_animation.rs @@ -0,0 +1,111 @@ +use std::convert::TryFrom; +use std::time::Duration; +use std::time::Instant; + +use rand::Rng as _; + +use crate::frames::ALL_VARIANTS; +use crate::frames::FRAME_TICK_DEFAULT; +use crate::tui::FrameRequester; + +/// Drives ASCII art animations shared across popups and onboarding widgets. +pub(crate) struct AsciiAnimation { + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + frame_tick: Duration, + start: Instant, +} + +impl AsciiAnimation { + pub(crate) fn new(request_frame: FrameRequester) -> Self { + Self::with_variants(request_frame, ALL_VARIANTS, 0) + } + + pub(crate) fn with_variants( + request_frame: FrameRequester, + variants: &'static [&'static [&'static str]], + variant_idx: usize, + ) -> Self { + assert!( + !variants.is_empty(), + "AsciiAnimation requires at least one animation variant", + ); + let clamped_idx = variant_idx.min(variants.len() - 1); + Self { + request_frame, + variants, + variant_idx: clamped_idx, + frame_tick: FRAME_TICK_DEFAULT, + start: Instant::now(), + } + } + + pub(crate) fn schedule_next_frame(&self) { + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + self.request_frame.schedule_frame(); + return; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let rem_ms = elapsed_ms % tick_ms; + let delay_ms = if rem_ms == 0 { + tick_ms + } else { + tick_ms - rem_ms + }; + if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { + self.request_frame + .schedule_frame_in(Duration::from_millis(delay_ms_u64)); + } else { + self.request_frame.schedule_frame(); + } + } + + pub(crate) fn current_frame(&self) -> &'static str { + let frames = self.frames(); + if frames.is_empty() { + return ""; + } + let tick_ms = self.frame_tick.as_millis(); + if tick_ms == 0 { + return frames[0]; + } + let elapsed_ms = self.start.elapsed().as_millis(); + let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; + frames[idx] + } + + pub(crate) fn pick_random_variant(&mut self) -> bool { + if self.variants.len() <= 1 { + return false; + } + let mut rng = rand::rng(); + let mut next = self.variant_idx; + while next == self.variant_idx { + next = rng.random_range(0..self.variants.len()); + } + self.variant_idx = next; + self.request_frame.schedule_frame(); + true + } + + #[allow(dead_code)] + pub(crate) fn request_frame(&self) { + self.request_frame.schedule_frame(); + } + + fn frames(&self) -> &'static [&'static str] { + self.variants[self.variant_idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_tick_must_be_nonzero() { + assert!(FRAME_TICK_DEFAULT.as_millis() > 0); + } +} diff --git a/codex-rs/tui2/src/bin/md-events2.rs b/codex-rs/tui2/src/bin/md-events2.rs new file mode 100644 index 0000000000..f1117fad91 --- /dev/null +++ b/codex-rs/tui2/src/bin/md-events2.rs @@ -0,0 +1,15 @@ +use std::io::Read; +use std::io::{self}; + +fn main() { + let mut input = String::new(); + if let Err(err) = io::stdin().read_to_string(&mut input) { + eprintln!("failed to read stdin: {err}"); + std::process::exit(1); + } + + let parser = pulldown_cmark::Parser::new(&input); + for event in parser { + println!("{event:?}"); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs new file mode 100644 index 0000000000..d42861eb1d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,717 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::bottom_pane::list_selection_view::SelectionItem; +use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::diff_render::DiffSummary; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use codex_core::features::Feature; +use codex_core::features::Features; +use codex_core::protocol::ElicitationAction; +use codex_core::protocol::ExecPolicyAmendment; +use codex_core::protocol::FileChange; +use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use mcp_types::RequestId; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +/// Request coming from the agent that needs user approval. +#[derive(Clone, Debug)] +pub(crate) enum ApprovalRequest { + Exec { + id: String, + command: Vec, + reason: Option, + proposed_execpolicy_amendment: Option, + }, + ApplyPatch { + id: String, + reason: Option, + cwd: PathBuf, + changes: HashMap, + }, + McpElicitation { + server_name: String, + request_id: RequestId, + message: String, + }, +} + +/// Modal overlay asking the user to approve or deny one or more requests. +pub(crate) struct ApprovalOverlay { + current_request: Option, + current_variant: Option, + queue: Vec, + app_event_tx: AppEventSender, + list: ListSelectionView, + options: Vec, + current_complete: bool, + done: bool, + features: Features, +} + +impl ApprovalOverlay { + pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { + let mut view = Self { + current_request: None, + current_variant: None, + queue: Vec::new(), + app_event_tx: app_event_tx.clone(), + list: ListSelectionView::new(Default::default(), app_event_tx), + options: Vec::new(), + current_complete: false, + done: false, + features, + }; + view.set_current(request); + view + } + + pub fn enqueue_request(&mut self, req: ApprovalRequest) { + self.queue.push(req); + } + + fn set_current(&mut self, request: ApprovalRequest) { + self.current_request = Some(request.clone()); + let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); + self.current_variant = Some(variant.clone()); + self.current_complete = false; + let (options, params) = Self::build_options(variant, header, &self.features); + self.options = options; + self.list = ListSelectionView::new(params, self.app_event_tx.clone()); + } + + fn build_options( + variant: ApprovalVariant, + header: Box, + features: &Features, + ) -> (Vec, SelectionViewParams) { + let (options, title) = match &variant { + ApprovalVariant::Exec { + proposed_execpolicy_amendment, + .. + } => ( + exec_options(proposed_execpolicy_amendment.clone(), features), + "Would you like to run the following command?".to_string(), + ), + ApprovalVariant::ApplyPatch { .. } => ( + patch_options(), + "Would you like to make the following edits?".to_string(), + ), + ApprovalVariant::McpElicitation { server_name, .. } => ( + elicitation_options(), + format!("{server_name} needs your approval."), + ), + }; + + let header = Box::new(ColumnRenderable::with([ + Line::from(title.bold()).into(), + Line::from("").into(), + header, + ])); + + let items = options + .iter() + .map(|opt| SelectionItem { + name: opt.label.clone(), + display_shortcut: opt + .display_shortcut + .or_else(|| opt.additional_shortcuts.first().copied()), + dismiss_on_select: false, + ..Default::default() + }) + .collect(); + + let params = SelectionViewParams { + footer_hint: Some(Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to cancel".into(), + ])), + items, + header, + ..Default::default() + }; + + (options, params) + } + + fn apply_selection(&mut self, actual_idx: usize) { + if self.current_complete { + return; + } + let Some(option) = self.options.get(actual_idx) else { + return; + }; + if let Some(variant) = self.current_variant.as_ref() { + match (variant, &option.decision) { + (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { + self.handle_exec_decision(id, command, decision.clone()); + } + (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { + self.handle_patch_decision(id, decision.clone()); + } + ( + ApprovalVariant::McpElicitation { + server_name, + request_id, + }, + ApprovalDecision::McpElicitation(decision), + ) => { + self.handle_elicitation_decision(server_name, request_id, *decision); + } + _ => {} + } + } + + self.current_complete = true; + self.advance_queue(); + } + + fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { + id: id.to_string(), + decision, + })); + } + + fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { + self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { + id: id.to_string(), + decision, + })); + } + + fn handle_elicitation_decision( + &self, + server_name: &str, + request_id: &RequestId, + decision: ElicitationAction, + ) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::ResolveElicitation { + server_name: server_name.to_string(), + request_id: request_id.clone(), + decision, + })); + } + + fn advance_queue(&mut self) { + if let Some(next) = self.queue.pop() { + self.set_current(next); + } else { + self.done = true; + } + } + + fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { + match key_event { + KeyEvent { + kind: KeyEventKind::Press, + code: KeyCode::Char('a'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(request) = self.current_request.as_ref() { + self.app_event_tx + .send(AppEvent::FullScreenApprovalRequest(request.clone())); + true + } else { + false + } + } + e => { + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) + { + self.apply_selection(idx); + true + } else { + false + } + } + } + } +} + +impl BottomPaneView for ApprovalOverlay { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.try_handle_shortcut(&key_event) { + return; + } + self.list.handle_key_event(key_event); + if let Some(idx) = self.list.take_last_selected_index() { + self.apply_selection(idx); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.done { + return CancellationEvent::Handled; + } + if !self.current_complete + && let Some(variant) = self.current_variant.as_ref() + { + match &variant { + ApprovalVariant::Exec { id, command, .. } => { + self.handle_exec_decision(id, command, ReviewDecision::Abort); + } + ApprovalVariant::ApplyPatch { id, .. } => { + self.handle_patch_decision(id, ReviewDecision::Abort); + } + ApprovalVariant::McpElicitation { + server_name, + request_id, + } => { + self.handle_elicitation_decision( + server_name, + request_id, + ElicitationAction::Cancel, + ); + } + } + } + self.queue.clear(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + self.enqueue_request(request); + None + } +} + +impl Renderable for ApprovalOverlay { + fn desired_height(&self, width: u16) -> u16 { + self.list.desired_height(width) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.list.render(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } +} + +struct ApprovalRequestState { + variant: ApprovalVariant, + header: Box, +} + +impl From for ApprovalRequestState { + fn from(value: ApprovalRequest) -> Self { + match value { + ApprovalRequest::Exec { + id, + command, + reason, + proposed_execpolicy_amendment, + } => { + let mut header: Vec> = Vec::new(); + if let Some(reason) = reason { + header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); + header.push(Line::from("")); + } + let full_cmd = strip_bash_lc_and_escape(&command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } + header.extend(full_cmd_lines); + Self { + variant: ApprovalVariant::Exec { + id, + command, + proposed_execpolicy_amendment, + }, + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + } + } + ApprovalRequest::ApplyPatch { + id, + reason, + cwd, + changes, + } => { + let mut header: Vec> = Vec::new(); + if let Some(reason) = reason + && !reason.is_empty() + { + header.push(Box::new( + Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()])) + .wrap(Wrap { trim: false }), + )); + header.push(Box::new(Line::from(""))); + } + header.push(DiffSummary::new(changes, cwd).into()); + Self { + variant: ApprovalVariant::ApplyPatch { id }, + header: Box::new(ColumnRenderable::with(header)), + } + } + ApprovalRequest::McpElicitation { + server_name, + request_id, + message, + } => { + let header = Paragraph::new(vec![ + Line::from(vec!["Server: ".into(), server_name.clone().bold()]), + Line::from(""), + Line::from(message), + ]) + .wrap(Wrap { trim: false }); + Self { + variant: ApprovalVariant::McpElicitation { + server_name, + request_id, + }, + header: Box::new(header), + } + } + } + } +} + +#[derive(Clone)] +enum ApprovalVariant { + Exec { + id: String, + command: Vec, + proposed_execpolicy_amendment: Option, + }, + ApplyPatch { + id: String, + }, + McpElicitation { + server_name: String, + request_id: RequestId, + }, +} + +#[derive(Clone)] +enum ApprovalDecision { + Review(ReviewDecision), + McpElicitation(ElicitationAction), +} + +#[derive(Clone)] +struct ApprovalOption { + label: String, + decision: ApprovalDecision, + display_shortcut: Option, + additional_shortcuts: Vec, +} + +impl ApprovalOption { + fn shortcuts(&self) -> impl Iterator + '_ { + self.display_shortcut + .into_iter() + .chain(self.additional_shortcuts.iter().copied()) + } +} + +fn exec_options( + proposed_execpolicy_amendment: Option, + features: &Features, +) -> Vec { + vec![ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }] + .into_iter() + .chain( + proposed_execpolicy_amendment + .filter(|_| features.enabled(Feature::ExecPolicy)) + .map(|prefix| { + let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); + ApprovalOption { + label: format!( + "Yes, and don't ask again for commands that start with `{rendered_prefix}`" + ), + decision: ApprovalDecision::Review( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, + }, + ), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + } + }), + ) + .chain([ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }]) + .collect() +} + +fn patch_options() -> Vec { + vec![ + ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ] +} + +fn elicitation_options() -> Vec { + vec![ + ApprovalOption { + label: "Yes, provide the requested info".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "No, but continue without it".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ApprovalOption { + label: "Cancel this request".to_string(), + decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn make_exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + reason: Some("reason".to_string()), + proposed_execpolicy_amendment: None, + } + } + + #[test] + fn ctrl_c_aborts_and_clears_queue() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + view.enqueue_request(make_exec_request()); + assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); + assert!(view.queue.is_empty()); + assert!(view.is_complete()); + } + + #[test] + fn shortcut_triggers_selection() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + assert!(!view.is_complete()); + view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + // We expect at least one CodexOp message in the queue. + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::CodexOp(_)) { + saw_op = true; + break; + } + } + assert!(saw_op, "expected approval decision to emit an op"); + } + + #[test] + fn exec_prefix_option_emits_execpolicy_amendment() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + Features::with_defaults(), + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + assert_eq!( + decision, + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ + "echo".to_string() + ]) + } + ); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with command prefix" + ); + } + + #[test] + fn exec_prefix_option_hidden_when_execpolicy_disabled() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + { + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + features + }, + ); + assert_eq!(view.options.len(), 2); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn header_includes_command_snippet() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let command = vec!["echo".into(), "hello".into(), "world".into()]; + let exec_request = ApprovalRequest::Exec { + id: "test".into(), + command, + reason: None, + proposed_execpolicy_amendment: None, + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); + view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); + + let rendered: Vec = (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect() + }) + .collect(); + assert!( + rendered + .iter() + .any(|line| line.contains("echo hello world")), + "expected header to include command snippet, got {rendered:?}" + ); + } + + #[test] + fn exec_history_cell_wraps_with_two_space_indent() { + let command = vec![ + "/bin/zsh".into(), + "-lc".into(), + "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), + ]; + let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); + let lines = cell.display_lines(28); + let rendered: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + let expected = vec![ + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), + ]; + assert_eq!(rendered, expected); + } + + #[test] + fn enter_sets_last_selected_index_without_dismissing() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "exec approval should complete without queued requests" + ); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ReviewDecision::Approved)); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs new file mode 100644 index 0000000000..499801cbb0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs @@ -0,0 +1,37 @@ +use crate::bottom_pane::ApprovalRequest; +use crate::render::renderable::Renderable; +use crossterm::event::KeyEvent; + +use super::CancellationEvent; + +/// Trait implemented by every view that can be shown in the bottom pane. +pub(crate) trait BottomPaneView: Renderable { + /// Handle a key event while the view is active. A redraw is always + /// scheduled after this call. + fn handle_key_event(&mut self, _key_event: KeyEvent) {} + + /// Return `true` if the view has finished and should be removed. + fn is_complete(&self) -> bool { + false + } + + /// Handle Ctrl-C while this view is active. + fn on_ctrl_c(&mut self) -> CancellationEvent { + CancellationEvent::NotHandled + } + + /// Optional paste handler. Return true if the view modified its state and + /// needs a redraw. + fn handle_paste(&mut self, _pasted: String) -> bool { + false + } + + /// Try to handle approval request; return the original value if not + /// consumed. + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + Some(request) + } +} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs new file mode 100644 index 0000000000..ed498e949c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -0,0 +1,3990 @@ +use crate::key_hint::has_ctrl_or_alt; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Margin; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; + +use super::chat_composer_history::ChatComposerHistory; +use super::command_popup::CommandItem; +use super::command_popup::CommandPopup; +use super::file_search_popup::FileSearchPopup; +use super::footer::FooterMode; +use super::footer::FooterProps; +use super::footer::esc_hint_mode; +use super::footer::footer_height; +use super::footer::render_footer; +use super::footer::reset_mode_after_activity; +use super::footer::toggle_shortcut_mode; +use super::paste_burst::CharDecision; +use super::paste_burst::PasteBurst; +use super::skill_popup::SkillPopup; +use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; +use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; +use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; +use crate::style::user_message_style; +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; +use crate::clipboard_paste::normalize_pasted_path; +use crate::clipboard_paste::pasted_image_format; +use crate::history_cell; +use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_core::skills::model::SkillMetadata; +use codex_file_search::FileMatch; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +/// If the pasted content exceeds this number of characters, replace it with a +/// placeholder in the UI. +const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; + +/// Result returned when the user interacts with the text area. +#[derive(Debug, PartialEq)] +pub enum InputResult { + Submitted(String), + Command(SlashCommand), + None, +} + +#[derive(Clone, Debug, PartialEq)] +struct AttachedImage { + placeholder: String, + path: PathBuf, +} + +enum PromptSelectionMode { + Completion, + Submit, +} + +enum PromptSelectionAction { + Insert { text: String, cursor: Option }, + Submit { text: String }, +} + +pub(crate) struct ChatComposer { + textarea: TextArea, + textarea_state: RefCell, + active_popup: ActivePopup, + app_event_tx: AppEventSender, + history: ChatComposerHistory, + ctrl_c_quit_hint: bool, + esc_backtrack_hint: bool, + use_shift_enter_hint: bool, + dismissed_file_popup_token: Option, + current_file_query: Option, + pending_pastes: Vec<(String, String)>, + large_paste_counters: HashMap, + has_focus: bool, + attached_images: Vec, + placeholder_text: String, + is_task_running: bool, + // Non-bracketed paste burst tracker. + paste_burst: PasteBurst, + // When true, disables paste-burst logic and inserts characters immediately. + disable_paste_burst: bool, + custom_prompts: Vec, + footer_mode: FooterMode, + footer_hint_override: Option>, + context_window_percent: Option, + context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, +} + +/// Popup state – at most one can be visible at any time. +enum ActivePopup { + None, + Command(CommandPopup), + File(FileSearchPopup), + Skill(SkillPopup), +} + +const FOOTER_SPACING_HEIGHT: u16 = 0; + +impl ChatComposer { + pub fn new( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + ) -> Self { + let use_shift_enter_hint = enhanced_keys_supported; + + let mut this = Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + active_popup: ActivePopup::None, + app_event_tx, + history: ChatComposerHistory::new(), + ctrl_c_quit_hint: false, + esc_backtrack_hint: false, + use_shift_enter_hint, + dismissed_file_popup_token: None, + current_file_query: None, + pending_pastes: Vec::new(), + large_paste_counters: HashMap::new(), + has_focus: has_input_focus, + attached_images: Vec::new(), + placeholder_text, + is_task_running: false, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + custom_prompts: Vec::new(), + footer_mode: FooterMode::ShortcutSummary, + footer_hint_override: None, + context_window_percent: None, + context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, + }; + // Apply configuration via the setter to keep side-effects centralized. + this.set_disable_paste_burst(disable_paste_burst); + this + } + + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + } + + fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + let popup_constraint = match &self.active_popup { + ActivePopup::Command(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::None => Constraint::Max(footer_total_height), + }; + let [composer_rect, popup_rect] = + Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); + let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); + [composer_rect, textarea_rect, popup_rect] + } + + fn footer_spacing(footer_hint_height: u16) -> u16 { + if footer_hint_height == 0 { + 0 + } else { + FOOTER_SPACING_HEIGHT + } + } + + /// Returns true if the composer currently contains no user input. + pub(crate) fn is_empty(&self) -> bool { + self.textarea.is_empty() + } + + /// Record the history metadata advertised by `SessionConfiguredEvent` so + /// that the composer can navigate cross-session history. + pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { + self.history.set_metadata(log_id, entry_count); + } + + /// Integrate an asynchronous response to an on-demand history lookup. If + /// the entry is present and the offset matches the current cursor we + /// immediately populate the textarea. + pub(crate) fn on_history_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) -> bool { + let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { + return false; + }; + self.set_text_content(text); + true + } + + pub fn handle_paste(&mut self, pasted: String) -> bool { + let char_count = pasted.chars().count(); + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + let placeholder = self.next_large_paste_placeholder(char_count); + self.textarea.insert_element(&placeholder); + self.pending_pastes.push((placeholder, pasted)); + } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { + self.textarea.insert_str(" "); + } else { + self.textarea.insert_str(&pasted); + } + // Explicit paste events should not trigger Enter suppression. + self.paste_burst.clear_after_explicit_paste(); + self.sync_popups(); + true + } + + pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { + let Some(path_buf) = normalize_pasted_path(&pasted) else { + return false; + }; + + // normalize_pasted_path already handles Windows → WSL path conversion, + // so we can directly try to read the image dimensions. + match image::image_dimensions(&path_buf) { + Ok((w, h)) => { + tracing::info!("OK: {pasted}"); + let format_label = pasted_image_format(&path_buf).label(); + self.attach_image(path_buf, w, h, format_label); + true + } + Err(err) => { + tracing::trace!("ERR: {err}"); + false + } + } + } + + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + let was_disabled = self.disable_paste_burst; + self.disable_paste_burst = disabled; + if disabled && !was_disabled { + self.paste_burst.clear_window_after_non_char(); + } + } + + /// Override the footer hint items displayed beneath the composer. Passing + /// `None` restores the default shortcut footer. + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.footer_hint_override = items; + } + + /// Replace the entire composer content with `text` and reset cursor. + pub(crate) fn set_text_content(&mut self, text: String) { + // Clear any existing content, placeholders, and attachments first. + self.textarea.set_text(""); + self.pending_pastes.clear(); + self.attached_images.clear(); + self.textarea.set_text(&text); + self.textarea.set_cursor(0); + self.sync_popups(); + } + + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { + if self.is_empty() { + return None; + } + let previous = self.current_text(); + self.set_text_content(String::new()); + self.history.reset_navigation(); + self.history.record_local_submission(&previous); + Some(previous) + } + + /// Get the current composer text. + pub(crate) fn current_text(&self) -> String { + self.textarea.text().to_string() + } + + /// Attempt to start a burst by retro-capturing recent chars before the cursor. + pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) { + let file_label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_string()); + let placeholder = format!("[{file_label} {width}x{height}]"); + // Insert as an element to match large paste placeholder behavior: + // styled distinctly and treated atomically for cursor/mutations. + self.textarea.insert_element(&placeholder); + self.attached_images + .push(AttachedImage { placeholder, path }); + } + + pub fn take_recent_submission_images(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images.into_iter().map(|img| img.path).collect() + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.handle_paste_burst_flush(Instant::now()) + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.paste_burst.is_active() + } + + pub(crate) fn recommended_paste_flush_delay() -> Duration { + PasteBurst::recommended_flush_delay() + } + + /// Integrate results from an asynchronous file search. + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + // Only apply if user is still editing a token starting with `query`. + let current_opt = Self::current_at_token(&self.textarea); + let Some(current_token) = current_opt else { + return; + }; + + if !current_token.starts_with(&query) { + return; + } + + if let ActivePopup::File(popup) = &mut self.active_popup { + popup.set_matches(&query, matches); + } + } + + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { + self.ctrl_c_quit_hint = show; + if show { + self.footer_mode = FooterMode::CtrlCReminder; + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + self.set_has_focus(has_focus); + } + + fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { + let base = format!("[Pasted Content {char_count} chars]"); + let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); + *next_suffix += 1; + if *next_suffix == 1 { + base + } else { + format!("{base} #{next_suffix}") + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.textarea.insert_str(text); + self.sync_popups(); + } + + /// Handle a key event coming from the main UI. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let result = match &mut self.active_popup { + ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), + ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), + ActivePopup::None => self.handle_key_event_without_popup(key_event), + }; + + // Update (or hide/show) popup after processing the key. + self.sync_popups(); + + result + } + + /// Return true if either the slash-command popup or the file-search popup is active. + pub(crate) fn popup_active(&self) -> bool { + !matches!(self.active_popup, ActivePopup::None) + } + + /// Handle key event when the slash-command popup is visible. + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + let ActivePopup::Command(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + // Dismiss the slash popup; keep the current input untouched. + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } => { + // Ensure popup filtering/selection reflects the latest composer text + // before applying completion. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); + if let Some(sel) = popup.selected_item() { + let mut cursor_target: Option = None; + match sel { + CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea.set_text(&format!("/{} ", cmd.command())); + } + if !self.textarea.text().is_empty() { + cursor_target = Some(self.textarea.text().len()); + } + } + CommandItem::UserPrompt(idx) => { + if let Some(prompt) = popup.prompt(idx) { + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Completion, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + cursor_target = Some(target); + } + PromptSelectionAction::Submit { .. } => {} + } + } + } + } + if let Some(pos) = cursor_target { + self.textarea.set_cursor(pos); + } + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + // If the current line starts with a custom prompt name and includes + // positional args for a numeric-style template, expand and submit + // immediately regardless of the popup selection. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, _rest)) = parse_slash_name(first_line) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) + && let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line) + { + self.textarea.set_text(""); + return (InputResult::Submitted(expanded), true); + } + + if let Some(sel) = popup.selected_item() { + match sel { + CommandItem::Builtin(cmd) => { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + CommandItem::UserPrompt(idx) => { + if let Some(prompt) = popup.prompt(idx) { + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Submit, + ) { + PromptSelectionAction::Submit { text } => { + self.textarea.set_text(""); + return (InputResult::Submitted(text), true); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + self.textarea.set_cursor(target); + return (InputResult::None, true); + } + } + } + return (InputResult::None, true); + } + } + } + // Fallback to default newline handling if no command selected. + self.handle_key_event_without_popup(key_event) + } + input => self.handle_input_basic(input), + } + } + + #[inline] + fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { + let mut p = pos.min(text.len()); + if p < text.len() && !text.is_char_boundary(p) { + p = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= p) + .last() + .unwrap_or(0); + } + p + } + + #[inline] + fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { + if let KeyEvent { + code: KeyCode::Char(ch), + .. + } = input + { + let now = Instant::now(); + if self.paste_burst.try_append_char_if_active(ch, now) { + return (InputResult::None, true); + } + } + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.textarea.input(input); + let text_after = self.textarea.text(); + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + (InputResult::None, true) + } + + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + let ActivePopup::File(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + // Hide popup without modifying text, remember token to avoid immediate reopen. + if let Some(tok) = Self::current_at_token(&self.textarea) { + self.dismissed_file_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let Some(sel) = popup.selected_match() else { + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + }; + + let sel_path = sel.to_string(); + // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. + let is_image = Self::is_image_path(&sel_path); + if is_image { + // Determine dimensions; if that fails fall back to normal path insertion. + let path_buf = PathBuf::from(&sel_path); + if let Ok((w, h)) = image::image_dimensions(&path_buf) { + // Remove the current @token (mirror logic from insert_selected_path without inserting text) + // using the flat text and byte-offset cursor API. + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Determine token boundaries in the full text. + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + self.textarea.replace_range(start_idx..end_idx, ""); + self.textarea.set_cursor(start_idx); + + let format_label = match Path::new(&sel_path) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + { + Some(ext) if ext == "png" => "PNG", + Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", + _ => "IMG", + }; + self.attach_image(path_buf, w, h, format_label); + // Add a trailing space to keep typing fluid. + self.textarea.insert_str(" "); + } else { + // Fallback to plain path insertion if metadata read fails. + self.insert_selected_path(&sel_path); + } + } else { + // Non-image: inserting file path. + self.insert_selected_path(&sel_path); + } + // No selection: treat Enter as closing the popup/session. + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + let ActivePopup::Skill(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_skill_token() { + self.dismissed_skill_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let selected = popup.selected_skill().map(|skill| skill.name.clone()); + if let Some(name) = selected { + self.insert_selected_skill(&name); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + + fn is_image_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") + } + + fn skills_enabled(&self) -> bool { + self.skills.as_ref().is_some_and(|s| !s.is_empty()) + } + + /// Extract a token prefixed with `prefix` under the cursor, if any. + /// + /// The returned string **does not** include the prefix. + /// + /// Behavior: + /// - The cursor may be anywhere *inside* the token (including on the + /// leading prefix). It does **not** need to be at the end of the line. + /// - A token is delimited by ASCII whitespace (space, tab, newline). + /// - If the token under the cursor starts with `prefix`, that token is + /// returned without the leading prefix. When `allow_empty` is true, a + /// lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { + let cursor_offset = textarea.cursor(); + let text = textarea.text(); + + // Adjust the provided byte offset to the nearest valid char boundary at or before it. + let mut safe_cursor = cursor_offset.min(text.len()); + // If we're not on a char boundary, move back to the start of the current char. + if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { + // Find the last valid boundary <= cursor_offset. + safe_cursor = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= cursor_offset) + .last() + .unwrap_or(0); + } + + // Split the line around the (now safe) cursor position. + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Detect whether we're on whitespace at the cursor boundary. + let at_whitespace = if safe_cursor < text.len() { + text[safe_cursor..] + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false) + } else { + false + }; + + // Left candidate: token containing the cursor position. + let start_left = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let end_left_rel = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_left = safe_cursor + end_left_rel; + let token_left = if start_left < end_left { + Some(&text[start_left..end_left]) + } else { + None + }; + + // Right candidate: token immediately after any whitespace from the cursor. + let ws_len_right: usize = after_cursor + .chars() + .take_while(|c| c.is_whitespace()) + .map(char::len_utf8) + .sum(); + let start_right = safe_cursor + ws_len_right; + let end_right_rel = text[start_right..] + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(text.len() - start_right); + let end_right = start_right + end_right_rel; + let token_right = if start_right < end_right { + Some(&text[start_right..end_right]) + } else { + None + }; + + let prefix_str = prefix.to_string(); + let left_match = token_left.filter(|t| t.starts_with(prefix)); + let right_match = token_right.filter(|t| t.starts_with(prefix)); + + let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); + let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); + + if at_whitespace { + if right_prefixed.is_some() { + return right_prefixed; + } + if token_left.is_some_and(|t| t == prefix_str) { + return allow_empty.then(String::new); + } + return left_prefixed; + } + if after_cursor.starts_with(prefix) { + return right_prefixed.or(left_prefixed); + } + left_prefixed.or(right_prefixed) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', false) + } + + fn current_skill_token(&self) -> Option { + if !self.skills_enabled() { + return None; + } + Self::current_prefixed_token(&self.textarea, '$', true) + } + + /// Replace the active `@token` (the one under the cursor) with `path`. + /// + /// The algorithm mirrors `current_at_token` so replacement works no matter + /// where the cursor is within the token and regardless of how many + /// `@tokens` exist in the line. + fn insert_selected_path(&mut self, path: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + // Determine token boundaries. + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + // If the path contains whitespace, wrap it in double quotes so the + // local prompt arg parser treats it as a single argument. Avoid adding + // quotes when the path already contains one to keep behavior simple. + let needs_quotes = path.chars().any(char::is_whitespace); + let inserted = if needs_quotes && !path.contains('"') { + format!("\"{path}\"") + } else { + path.to_string() + }; + + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + + fn insert_selected_skill(&mut self, skill_name: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + let inserted = format!("${skill_name}"); + + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + + /// Handle key event when no popup is visible. + fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + if self.is_empty() { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + match key_event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } if self.is_empty() => { + self.app_event_tx.send(AppEvent::ExitRequest); + (InputResult::None, true) + } + // ------------------------------------------------------------- + // History navigation (Up / Down) – only when the composer is not + // empty or when the cursor is at the correct position, to avoid + // interfering with normal cursor movement. + // ------------------------------------------------------------- + KeyEvent { + code: KeyCode::Up | KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('p') | KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + if self + .history + .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) + { + let replace_text = match key_event.code { + KeyCode::Up => self.history.navigate_up(&self.app_event_tx), + KeyCode::Down => self.history.navigate_down(&self.app_event_tx), + KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), + KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), + _ => unreachable!(), + }; + if let Some(text) = replace_text { + self.set_text_content(text); + return (InputResult::None, true); + } + } + self.handle_input_basic(key_event) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter should still dispatch the command rather than submit + // literal text. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + } + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + input => self.handle_input_basic(input), + } + } + + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { + match self.paste_burst.flush_if_due(now) { + FlushResult::Paste(pasted) => { + self.handle_paste(pasted); + true + } + FlushResult::Typed(ch) => { + // Mirror insert_str() behavior so popups stay in sync when a + // pending fast char flushes as normal typed input. + self.textarea.insert_str(ch.to_string().as_str()); + self.sync_popups(); + true + } + FlushResult::None => false, + } + } + + /// Handle generic Input events that modify the textarea content. + fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // If we have a buffered non-bracketed paste burst and enough time has + // elapsed since the last char, flush it before handling a new input. + let now = Instant::now(); + self.handle_paste_burst_flush(now); + + if !matches!(input.code, KeyCode::Esc) { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + // If we're capturing a burst and receive Enter, accumulate it instead of inserting. + if matches!(input.code, KeyCode::Enter) + && self.paste_burst.is_active() + && self.paste_burst.append_newline_if_active(now) + { + return (InputResult::None, true); + } + + // Intercept plain Char inputs to optionally accumulate into a burst buffer. + if let KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } = input + { + let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); + if !has_ctrl_or_alt { + // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be + // misclassified by paste heuristics. Flush any active burst buffer and insert + // non-ASCII characters directly. + if !ch.is_ascii() { + return self.handle_non_ascii_char(input); + } + + match self.paste_burst.on_plain_char(ch, now) { + CharDecision::BufferAppend => { + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::BeginBuffer { retro_chars } => { + let cur = self.textarea.cursor(); + let txt = self.textarea.text(); + let safe_cur = Self::clamp_to_char_boundary(txt, cur); + let before = &txt[..safe_cur]; + if let Some(grab) = + self.paste_burst + .decide_begin_buffer(now, before, retro_chars as usize) + { + if !grab.grabbed.is_empty() { + self.textarea.replace_range(grab.start_byte..safe_cur, ""); + } + self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now); + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. + } + CharDecision::BeginBufferFromPending => { + // First char was held; now append the current one. + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::RetainFirstChar => { + // Keep the first fast char pending momentarily. + return (InputResult::None, true); + } + } + } + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + } + + // For non-char inputs (or after flushing), handle normally. + // Special handling for backspace on placeholders + if let KeyEvent { + code: KeyCode::Backspace, + .. + } = input + && self.try_remove_any_placeholder_at_cursor() + { + return (InputResult::None, true); + } + + // Normal input handling + self.textarea.input(input); + let text_after = self.textarea.text(); + + // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. + let crossterm::event::KeyEvent { + code, modifiers, .. + } = input; + match code { + KeyCode::Char(_) => { + let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); + if has_ctrl_or_alt { + self.paste_burst.clear_window_after_non_char(); + } + } + KeyCode::Enter => { + // Keep burst window alive (supports blank lines in paste). + } + _ => { + // Other keys: clear burst window (buffer should have been flushed above if needed). + self.paste_burst.clear_window_after_non_char(); + } + } + + // Check if any placeholders were removed and remove their corresponding pending pastes + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + + // Keep attached images in proportion to how many matching placeholders exist in the text. + // This handles duplicate placeholders that share the same visible label. + if !self.attached_images.is_empty() { + let mut needed: HashMap = HashMap::new(); + for img in &self.attached_images { + needed + .entry(img.placeholder.clone()) + .or_insert_with(|| text_after.matches(&img.placeholder).count()); + } + + let mut used: HashMap = HashMap::new(); + let mut kept: Vec = Vec::with_capacity(self.attached_images.len()); + for img in self.attached_images.drain(..) { + let total_needed = *needed.get(&img.placeholder).unwrap_or(&0); + let used_count = used.entry(img.placeholder.clone()).or_insert(0); + if *used_count < total_needed { + kept.push(img); + *used_count += 1; + } + } + self.attached_images = kept; + } + + (InputResult::None, true) + } + + /// Attempts to remove an image or paste placeholder if the cursor is at the end of one. + /// Returns true if a placeholder was removed. + fn try_remove_any_placeholder_at_cursor(&mut self) -> bool { + // Clamp the cursor to a valid char boundary to avoid panics when slicing. + let text = self.textarea.text(); + let p = Self::clamp_to_char_boundary(text, self.textarea.cursor()); + + // Try image placeholders first + let mut out: Option<(usize, String)> = None; + // Detect if the cursor is at the end of any image placeholder. + // If duplicates exist, remove the specific occurrence's mapping. + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p < ph.len() { + continue; + } + let start = p - ph.len(); + if text.get(start..p) != Some(ph.as_str()) { + continue; + } + + // Count the number of occurrences of `ph` before `start`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < start { + let segment = match text.get(search_pos..start) { + Some(s) => s, + None => break, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break; + } + } + + // Remove the occ_before-th attached image that shares this placeholder label. + out = if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + Some((remove_idx, ph.clone())) + } else { + Some((i, ph.clone())) + }; + break; + } + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.attached_images.remove(idx); + return true; + } + + // Also handle when the cursor is at the START of an image placeholder. + // let result = 'out: { + let out: Option<(usize, String)> = 'out: { + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p + ph.len() > text.len() { + continue; + } + if text.get(p..p + ph.len()) != Some(ph.as_str()) { + continue; + } + + // Count occurrences of `ph` before `p`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < p { + let segment = match text.get(search_pos..p) { + Some(s) => s, + None => break 'out None, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break 'out None; + } + } + + if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + break 'out Some((remove_idx, ph.clone())); + } else { + break 'out Some((i, ph.clone())); + } + } + None + }; + + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.attached_images.remove(idx); + return true; + } + + // Then try pasted-content placeholders + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p < ph.len() { + return None; + } + let start = p - ph.len(); + if text.get(start..p) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + // Also handle when the cursor is at the START of a pasted-content placeholder. + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p + ph.len() > text.len() { + return None; + } + if text.get(p..p + ph.len()) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + false + } + + fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press { + return false; + } + + let toggles = matches!(key_event.code, KeyCode::Char('?')) + && !has_ctrl_or_alt(key_event.modifiers) + && self.is_empty(); + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed + } + + fn footer_props(&self) -> FooterProps { + FooterProps { + mode: self.footer_mode(), + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + is_task_running: self.is_task_running, + context_window_percent: self.context_window_percent, + context_window_used_tokens: self.context_window_used_tokens, + } + } + + fn footer_mode(&self) -> FooterMode { + match self.footer_mode { + FooterMode::EscHint => FooterMode::EscHint, + FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, + FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, + other => other, + } + } + + fn custom_footer_height(&self) -> Option { + self.footer_hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + let skill_token = self.current_skill_token(); + + let allow_command_popup = file_token.is_none() && skill_token.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + self.dismissed_skill_popup_token = None; + return; + } + + if let Some(token) = skill_token { + self.sync_skill_popup(token); + return; + } + self.dismissed_skill_popup_token = None; + + if let Some(token) = file_token { + self.sync_file_search_popup(token); + return; + } + + self.dismissed_file_popup_token = None; + if matches!( + self.active_popup, + ActivePopup::File(_) | ActivePopup::Skill(_) + ) { + self.active_popup = ActivePopup::None; + } + } + + /// If the cursor is currently within a slash command on the first line, + /// extract the command name and the rest of the line after it. + /// Returns None if the cursor is outside a slash command. + fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { + if !first_line.starts_with('/') { + return None; + } + + let name_start = 1usize; + let name_end = first_line[name_start..] + .find(char::is_whitespace) + .map(|idx| name_start + idx) + .unwrap_or_else(|| first_line.len()); + + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..name_end]; + let rest_start = first_line[name_end..] + .find(|c: char| !c.is_whitespace()) + .map(|idx| name_end + idx) + .unwrap_or(name_end); + let rest = &first_line[rest_start..]; + + Some((name, rest)) + } + + /// Heuristic for whether the typed slash command looks like a valid + /// prefix for any known command (built-in or custom prompt). + /// Empty names only count when there is no extra content after the '/'. + fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if name.is_empty() { + return rest_after_name.is_empty(); + } + + let builtin_match = built_in_slash_commands() + .into_iter() + .any(|(cmd_name, _)| cmd_name.starts_with(name)); + + if builtin_match { + return true; + } + + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + self.custom_prompts + .iter() + .any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name)) + } + + /// Synchronize `self.command_popup` with the current text in the + /// textarea. This must be called after every modification that can change + /// the text so the popup is shown/updated/hidden as appropriate. + fn sync_command_popup(&mut self, allow: bool) { + if !allow { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } + // Determine whether the caret is inside the initial '/name' token on the first line. + let text = self.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let first_line = &text[..first_line_end]; + let cursor = self.textarea.cursor(); + let caret_on_first_line = cursor <= first_line_end; + + let is_editing_slash_command_name = caret_on_first_line + && Self::slash_command_under_cursor(first_line, cursor) + .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); + + // If the cursor is currently positioned within an `@token`, prefer the + // file-search popup over the slash popup so users can insert a file path + // as an argument to the command (e.g., "/review @docs/..."). + if Self::current_at_token(&self.textarea).is_some() { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } + match &mut self.active_popup { + ActivePopup::Command(popup) => { + if is_editing_slash_command_name { + popup.on_composer_text_change(first_line.to_string()); + } else { + self.active_popup = ActivePopup::None; + } + } + _ => { + if is_editing_slash_command_name { + let skills_enabled = self.skills_enabled(); + let mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + command_popup.on_composer_text_change(first_line.to_string()); + self.active_popup = ActivePopup::Command(command_popup); + } + } + } + } + + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + self.custom_prompts = prompts.clone(); + if let ActivePopup::Command(popup) = &mut self.active_popup { + popup.set_prompts(prompts); + } + } + + /// Synchronize `self.file_search_popup` with the current text in the textarea. + /// Note this is only called when self.active_popup is NOT Command. + fn sync_file_search_popup(&mut self, query: String) { + // If user dismissed popup for this exact query, don't reopen until text changes. + if self.dismissed_file_popup_token.as_ref() == Some(&query) { + return; + } + + if !query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + } + + match &mut self.active_popup { + ActivePopup::File(popup) => { + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + } + _ => { + let mut popup = FileSearchPopup::new(); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } + self.active_popup = ActivePopup::File(popup); + } + } + + self.current_file_query = Some(query); + self.dismissed_file_popup_token = None; + } + + fn sync_skill_popup(&mut self, query: String) { + if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + return; + } + + let skills = match self.skills.as_ref() { + Some(skills) if !skills.is_empty() => skills.clone(), + _ => { + self.active_popup = ActivePopup::None; + return; + } + }; + + match &mut self.active_popup { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_skills(skills); + } + _ => { + let mut popup = SkillPopup::new(skills); + popup.set_query(&query); + self.active_popup = ActivePopup::Skill(popup); + } + } + } + + fn set_has_focus(&mut self, has_focus: bool) { + self.has_focus = has_focus; + } + + pub fn set_task_running(&mut self, running: bool) { + self.is_task_running = running; + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; + } + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; + } + + pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { + self.esc_backtrack_hint = show; + if show { + self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + } +} + +impl Renderable for ChatComposer { + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let [_, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + self.textarea + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + + 2 + + match &self.active_popup { + ActivePopup::None => footer_total_height, + ActivePopup::Command(c) => c.calculate_required_height(width), + ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Skill(c) => c.calculate_required_height(width), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); + match &self.active_popup { + ActivePopup::Command(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::File(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::None => { + let footer_props = self.footer_props(); + let custom_height = self.custom_footer_height(); + let footer_hint_height = + custom_height.unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if let Some(items) = self.footer_hint_override.as_ref() { + if !items.is_empty() { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + Line::from(spans).render_ref(custom_rect, buf); + } + } else { + render_footer(hint_rect, buf, footer_props); + } + } + } + let style = user_message_style(); + Block::default().style(style).render_ref(composer_rect, buf); + if !textarea_rect.is_empty() { + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &"›".bold(), + textarea_rect.width, + ); + } + + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + let placeholder = Span::from(self.placeholder_text.as_str()).dim(); + Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); + } + } +} + +fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); + PromptSelectionAction::Insert { text, cursor: None } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { + return PromptSelectionAction::Submit { text: expanded }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + PromptSelectionAction::Submit { + text: prompt.content.clone(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::ImageBuffer; + use image::Rgba; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use crate::bottom_pane::ChatComposer; + use crate::bottom_pane::InputResult; + use crate::bottom_pane::chat_composer::AttachedImage; + use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; + use crate::bottom_pane::textarea::TextArea; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn footer_hint_row_is_separated_from_composer() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let row_to_string = |y: u16| { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + row + }; + + let mut hint_row: Option<(u16, String)> = None; + for y in 0..area.height { + let row = row_to_string(y); + if row.contains("? for shortcuts") { + hint_row = Some((y, row)); + break; + } + } + + let (hint_row_idx, hint_row_contents) = + hint_row.expect("expected footer hint row to be rendered"); + assert_eq!( + hint_row_idx, + area.height - 1, + "hint row should occupy the bottom line: {hint_row_contents:?}", + ); + + assert!( + hint_row_idx > 0, + "expected a spacing row above the footer hints", + ); + + let spacing_row = row_to_string(hint_row_idx - 1); + assert_eq!( + spacing_row.trim(), + "", + "expected blank spacing row above hints but saw: {spacing_row:?}", + ); + } + + fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) + where + F: FnOnce(&mut ChatComposer), + { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let width = 100; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + enhanced_keys_supported, + "Ask Codex to do anything".to_string(), + false, + ); + setup(&mut composer); + let footer_props = composer.footer_props(); + let footer_lines = footer_height(footer_props); + let footer_spacing = ChatComposer::footer_spacing(footer_lines); + let height = footer_lines + footer_spacing + 8; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_mode_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { + composer.set_task_running(true); + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { + composer.set_ctrl_c_quit_hint(true, true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { + composer.set_esc_backtrack_hint(true); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + }); + + snapshot_composer_state( + "footer_mode_overlay_then_external_esc_hint", + true, + |composer| { + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.set_esc_backtrack_hint(true); + }, + ); + + snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| { + type_chars_humanlike(composer, &['h']); + }); + } + + #[test] + fn esc_hint_stays_hidden_with_draft_content() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['d']); + + assert!(!composer.is_empty()); + assert_eq!(composer.current_text(), "d"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(!composer.esc_backtrack_hint); + } + + #[test] + fn clear_for_ctrl_c_records_cleared_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("draft text".to_string()); + assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); + assert!(composer.is_empty()); + + assert_eq!( + composer.history.navigate_up(&composer.app_event_tx), + Some("draft text".to_string()) + ); + } + + #[test] + fn question_mark_only_toggles_on_first_char() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "toggling overlay should request redraw"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + // Toggle back to prompt mode so subsequent typing captures characters. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + + type_chars_humanlike(&mut composer, &['h']); + assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert!(needs_redraw, "typing should still mark the view dirty"); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + assert_eq!(composer.textarea.text(), "h?"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + } + + #[test] + fn shortcut_overlay_persists_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + + composer.set_task_running(true); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); + } + + #[test] + fn test_current_at_token_basic_cases() { + let test_cases = vec![ + // Valid @ tokens + ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"), + ( + "@file.txt", + 4, + Some("file.txt".to_string()), + "ASCII with extension", + ), + ( + "hello @world test", + 8, + Some("world".to_string()), + "ASCII token in middle", + ), + ( + "@test123", + 5, + Some("test123".to_string()), + "ASCII with numbers", + ), + // Unicode examples + ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"), + ( + "@testЙЦУ.rs", + 8, + Some("testЙЦУ.rs".to_string()), + "Mixed ASCII and Cyrillic", + ), + ("@诶", 2, Some("诶".to_string()), "Chinese character"), + ("@👍", 2, Some("👍".to_string()), "Emoji token"), + // Invalid cases (should return None) + ("hello", 2, None, "No @ symbol"), + ( + "@", + 1, + Some("".to_string()), + "Only @ symbol triggers empty query", + ), + ("@ hello", 2, None, "@ followed by space"), + ("test @ world", 6, None, "@ with spaces around"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" + ); + } + } + + #[test] + fn test_current_at_token_cursor_positions() { + let test_cases = vec![ + // Different cursor positions within a token + ("@test", 0, Some("test".to_string()), "Cursor at @"), + ("@test", 1, Some("test".to_string()), "Cursor after @"), + ("@test", 5, Some("test".to_string()), "Cursor at end"), + // Multiple tokens - cursor determines which token + ("@file1 @file2", 0, Some("file1".to_string()), "First token"), + ( + "@file1 @file2", + 8, + Some("file2".to_string()), + "Second token", + ), + // Edge cases + ("@", 0, Some("".to_string()), "Only @ symbol"), + ("@a", 2, Some("a".to_string()), "Single character after @"), + ("", 0, None, "Empty input"), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn test_current_at_token_whitespace_boundaries() { + let test_cases = vec![ + // Space boundaries + ( + "aaa@aaa", + 4, + None, + "Connected @ token - no completion by design", + ), + ( + "aaa @aaa", + 5, + Some("aaa".to_string()), + "@ token after space", + ), + ( + "test @file.txt", + 7, + Some("file.txt".to_string()), + "@ token after space", + ), + // Full-width space boundaries + ( + "test @İstanbul", + 8, + Some("İstanbul".to_string()), + "@ token after full-width space", + ), + ( + "@ЙЦУ @诶", + 10, + Some("诶".to_string()), + "Full-width space between Unicode tokens", + ), + // Tab and newline boundaries + ( + "test\t@file", + 6, + Some("file".to_string()), + "@ token after tab", + ), + ]; + + for (input, cursor_pos, expected, description) in test_cases { + let mut textarea = TextArea::new(); + textarea.insert_str(input); + textarea.set_cursor(cursor_pos); + + let result = ChatComposer::current_at_token(&textarea); + assert_eq!( + result, expected, + "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}", + ); + } + } + + #[test] + fn ascii_prefix_survives_non_ascii_followup() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "1あ"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn handle_paste_small_inserts_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let needs_redraw = composer.handle_paste("hello".to_string()); + assert!(needs_redraw); + assert_eq!(composer.textarea.text(), "hello"); + assert!(composer.pending_pastes.is_empty()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "hello"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn empty_enter_returns_none() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Ensure composer is empty and press Enter. + assert!(composer.textarea.text().is_empty()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::None => {} + other => panic!("expected None for empty enter, got: {other:?}"), + } + } + + #[test] + fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); + let needs_redraw = composer.handle_paste(large.clone()); + assert!(needs_redraw); + let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); + assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder); + assert_eq!(composer.pending_pastes[0].1, large); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, large), + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn edit_clears_pending_paste() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.handle_paste(large); + assert_eq!(composer.pending_pastes.len(), 1); + + // Any edit that removes the placeholder should clear pending_paste + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn ui_snapshots() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + + let test_cases = vec![ + ("empty", None), + ("small", Some("short".to_string())), + ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))), + ("multiple_pastes", None), + ("backspace_after_pastes", None), + ]; + + for (name, input) in test_cases { + // Create a fresh composer for each test case + let mut composer = ChatComposer::new( + true, + sender.clone(), + false, + "Ask Codex to do anything".to_string(), + false, + ); + + if let Some(text) = input { + composer.handle_paste(text); + } else if name == "multiple_pastes" { + // First large paste + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); + // Second large paste + composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); + // Small paste + composer.handle_paste(" another short paste".to_string()); + } else if name == "backspace_after_pastes" { + // Three large pastes + composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); + composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); + composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); + // Move cursor to end and press backspace + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); + + insta::assert_snapshot!(name, terminal.backend()); + } + } + + #[test] + fn slash_popup_model_first_for_mo_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/mo" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + // Visual snapshot should show the slash popup with /model as the first entry. + insta::assert_snapshot!("slash_popup_mo", terminal.backend()); + } + + #[test] + fn slash_popup_model_first_for_mo_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "model") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/mo'") + } + None => panic!("no selected command for '/mo'"), + }, + _ => panic!("slash popup not active after typing '/mo'"), + } + } + + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/res'") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer + fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + for &ch in chars { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + } + } + + #[test] + fn slash_init_dispatches_command_and_does_not_submit_literal_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type the slash command. + type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); + + // Press Enter to dispatch the selected command. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // When a slash command is dispatched, the composer should return a + // Command result (not submit literal text) and clear its textarea. + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "init"); + } + InputResult::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::None => panic!("expected Command result for '/init'"), + } + assert!(composer.textarea.is_empty(), "composer should be cleared"); + } + + #[test] + fn extract_args_supports_quoted_paths_single_arg() { + let args = extract_positional_args_for_prompt_line( + "/prompts:review \"docs/My File.md\"", + "review", + ); + assert_eq!(args, vec!["docs/My File.md".to_string()]); + } + + #[test] + fn extract_args_supports_mixed_quoted_and_unquoted() { + let args = + extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); + assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + } + + #[test] + fn slash_tab_completion_moves_cursor_to_end() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/', 'c']); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(composer.textarea.text(), "/compact "); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn slash_tab_then_enter_dispatches_builtin_command() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type a prefix and complete with Tab, which inserts a trailing space + // and moves the cursor beyond the '/name' token (hides the popup). + type_chars_humanlike(&mut composer, &['/', 'd', 'i']); + let (_res, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "/diff "); + + // Press Enter: should dispatch the command, not submit literal text. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::Submitted(text) => { + panic!("expected command dispatch after Tab completion, got literal submit: {text}") + } + InputResult::None => panic!("expected Command result for '/diff'"), + } + assert!(composer.textarea.is_empty()); + } + + #[test] + fn slash_mention_dispatches_command_and_inserts_at() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "mention"); + } + InputResult::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {text}") + } + InputResult::None => panic!("expected Command result for '/mention'"), + } + assert!(composer.textarea.is_empty(), "composer should be cleared"); + composer.insert_str("@"); + assert_eq!(composer.textarea.text(), "@"); + } + + #[test] + fn test_multiple_pastes_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Define test cases: (paste content, is_large) + let test_cases = [ + ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true), + (" and ".to_string(), false), + ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true), + ]; + + // Expected states after each paste + let mut expected_text = String::new(); + let mut expected_pending_count = 0; + + // Apply all pastes and build expected state + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + expected_text.push_str(&placeholder); + expected_pending_count += 1; + } else { + expected_text.push_str(content); + } + (expected_text.clone(), expected_pending_count) + }) + .collect(); + + // Verify all intermediate states were correct + assert_eq!( + states, + vec![ + ( + format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and ", + test_cases[0].0.chars().count() + ), + 1 + ), + ( + format!( + "[Pasted Content {} chars] and [Pasted Content {} chars]", + test_cases[0].0.chars().count(), + test_cases[2].0.chars().count() + ), + 2 + ), + ] + ); + + // Submit and verify final expansion + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + if let InputResult::Submitted(text) = result { + assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); + } else { + panic!("expected Submitted"); + } + } + + #[test] + fn test_placeholder_deletion() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Define test cases: (content, is_large) + let test_cases = [ + ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true), + (" and ".to_string(), false), + ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true), + ]; + + // Apply all pastes + let mut current_pos = 0; + let states: Vec<_> = test_cases + .iter() + .map(|(content, is_large)| { + composer.handle_paste(content.clone()); + if *is_large { + let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); + current_pos += placeholder.len(); + } else { + current_pos += content.len(); + } + ( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + current_pos, + ) + }) + .collect(); + + // Delete placeholders one by one and collect states + let mut deletion_states = vec![]; + + // First deletion + composer.textarea.set_cursor(states[0].2); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + )); + + // Second deletion + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + deletion_states.push(( + composer.textarea.text().to_string(), + composer.pending_pastes.len(), + )); + + // Verify all states + assert_eq!( + deletion_states, + vec![ + (" and [Pasted Content 1006 chars]".to_string(), 1), + (" and ".to_string(), 0), + ] + ); + } + + #[test] + fn deleting_duplicate_length_pastes_removes_only_target() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count()); + let placeholder_second = format!("{placeholder_base} #2"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!( + composer.textarea.text(), + format!("{placeholder_base}{placeholder_second}") + ); + assert_eq!(composer.pending_pastes.len(), 2); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.textarea.text(), placeholder_base); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.pending_pastes[0].1, paste); + } + + #[test] + fn large_paste_numbering_does_not_reuse_after_deletion() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + let second = format!("{base} #2"); + let third = format!("{base} #3"); + + composer.handle_paste(paste.clone()); + composer.handle_paste(paste.clone()); + assert_eq!(composer.textarea.text(), format!("{base}{second}")); + + composer.textarea.set_cursor(base.len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), second); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, second); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_paste(paste); + + assert_eq!(composer.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.pending_pastes.len(), 2); + assert_eq!(composer.pending_pastes[0].0, second); + assert_eq!(composer.pending_pastes[1].0, third); + } + + #[test] + fn test_partial_placeholder_deletion() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Define test cases: (cursor_position_from_end, expected_pending_count) + let test_cases = [ + 5, // Delete from middle - should clear tracking + 0, // Delete from end - should clear tracking + ]; + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let placeholder = format!("[Pasted Content {} chars]", paste.chars().count()); + + let states: Vec<_> = test_cases + .into_iter() + .map(|pos_from_end| { + composer.handle_paste(paste.clone()); + composer + .textarea + .set_cursor(placeholder.len() - pos_from_end); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + let result = ( + composer.textarea.text().contains(&placeholder), + composer.pending_pastes.len(), + ); + composer.textarea.set_text(""); + result + }) + .collect(); + + assert_eq!( + states, + vec![ + (false, 0), // After deleting from middle + (false, 0), // After deleting from end + ] + ); + } + + // --- Image attachment tests --- + #[test] + fn attach_image_and_submit_includes_image_paths() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + let path = PathBuf::from("/tmp/image1.png"); + composer.attach_image(path.clone(), 32, 16, "PNG"); + composer.handle_paste(" hi".into()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"), + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn attach_image_without_text_submits_empty_text_and_images() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + let path = PathBuf::from("/tmp/image2.png"); + composer.attach_image(path.clone(), 10, 5, "PNG"); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"), + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0], path); + assert!(composer.attached_images.is_empty()); + } + + #[test] + fn image_placeholder_backspace_behaves_like_text_placeholder() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + let path = PathBuf::from("/tmp/image3.png"); + composer.attach_image(path.clone(), 20, 10, "PNG"); + let placeholder = composer.attached_images[0].placeholder.clone(); + + // Case 1: backspace at end + composer.textarea.move_cursor_to_end_of_line(false); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder)); + assert!(composer.attached_images.is_empty()); + + // Re-add and test backspace in middle: should break the placeholder string + // and drop the image mapping (same as text placeholder behavior). + composer.attach_image(path, 20, 10, "PNG"); + let placeholder2 = composer.attached_images[0].placeholder.clone(); + // Move cursor to roughly middle of placeholder + if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + let mid_pos = start_pos + (placeholder2.len() / 2); + composer.textarea.set_cursor(mid_pos); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder2)); + assert!(composer.attached_images.is_empty()); + } else { + panic!("Placeholder not found in textarea"); + } + } + + #[test] + fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Insert an image placeholder at the start + let path = PathBuf::from("/tmp/image_multibyte.png"); + composer.attach_image(path, 10, 5, "PNG"); + // Add multibyte text after the placeholder + composer.textarea.insert_str("日本語"); + + // Cursor is at end; pressing backspace should delete the last character + // without panicking and leave the placeholder intact. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(composer.attached_images.len(), 1); + assert!( + composer + .textarea + .text() + .starts_with("[image_multibyte.png 10x5]") + ); + } + + #[test] + fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_dup1.png"); + let path2 = PathBuf::from("/tmp/image_dup2.png"); + + composer.attach_image(path1, 10, 5, "PNG"); + // separate placeholders with a space for clarity + composer.handle_paste(" ".into()); + composer.attach_image(path2.clone(), 10, 5, "PNG"); + + let placeholder1 = composer.attached_images[0].placeholder.clone(); + let placeholder2 = composer.attached_images[1].placeholder.clone(); + let text = composer.textarea.text().to_string(); + let start1 = text.find(&placeholder1).expect("first placeholder present"); + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + // Backspace should delete the first placeholder and its mapping. + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + let new_text = composer.textarea.text().to_string(); + assert_eq!( + 0, + new_text.matches(&placeholder1).count(), + "first placeholder removed" + ); + assert_eq!( + 1, + new_text.matches(&placeholder2).count(), + "second placeholder remains" + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: "[image_dup2.png 10x5]".to_string() + }], + composer.attached_images, + "one image mapping remains" + ); + } + + #[test] + fn pasting_filepath_attaches_image() { + let tmp = tempdir().expect("create TempDir"); + let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + img.save(&tmp_path).expect("failed to write temp png"); + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); + assert!(needs_redraw); + assert!( + composer + .textarea + .text() + .starts_with("[codex_tui_test_paste_image.png 3x2] ") + ); + + let imgs = composer.take_recent_submission_images(); + assert_eq!(imgs, vec![tmp_path]); + } + + #[test] + fn selecting_custom_prompt_without_args_submits_content() { + let prompt_text = "Hello from saved prompt"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Inject prompts as if received via event. + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', + ], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_submission_expands_arguments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Review Alice changes on main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_submission_accepts_quoted_values() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_with_large_paste_expands_correctly() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Create a custom prompt with positional args (no named args like $USER) + composer.set_custom_prompts(vec![CustomPrompt { + name: "code-review".to_string(), + path: "/tmp/code-review.md".to_string().into(), + content: "Please review the following code:\n\n$1".to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command + let command_text = "/prompts:code-review "; + composer.textarea.set_text(command_text); + composer.textarea.set_cursor(command_text.len()); + + // Paste large content (>3000 chars) to trigger placeholder + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000); + composer.handle_paste(large_content.clone()); + + // Verify placeholder was created + let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count()); + assert_eq!( + composer.textarea.text(), + format!("/prompts:code-review {}", placeholder) + ); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, placeholder); + assert_eq!(composer.pending_pastes[0].1, large_content); + + // Submit by pressing Enter + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Verify the custom prompt was expanded with the large content as positional arg + match result { + InputResult::Submitted(text) => { + // The prompt should be expanded, with the large content replacing $1 + assert_eq!( + text, + format!("Please review the following code:\n\n{}", large_content), + "Expected prompt expansion with large content as $1" + ); + } + _ => panic!("expected Submitted, got: {result:?}"), + } + assert!(composer.textarea.is_empty()); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn slash_path_input_submits_without_command_error() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer + .textarea + .set_text("/Users/example/project/src/main.rs"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/Users/example/project/src/main.rs"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn slash_with_leading_space_submits_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.textarea.set_text(" /this-looks-like-a-command"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/this-looks-like-a-command"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn custom_prompt_invalid_args_reports_error() { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice stray"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!( + "/prompts:my-prompt USER=Alice stray", + composer.textarea.text() + ); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains("expected key=value")); + found_error = true; + break; + } + } + assert!(found_error, "expected error history cell to be sent"); + } + + #[test] + fn custom_prompt_missing_required_args_reports_error() { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + // Provide only one of the required args + composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.to_lowercase().contains("missing required args")); + assert!(message.contains("BRANCH")); + found_error = true; + break; + } + } + assert!( + found_error, + "expected missing args error history cell to be sent" + ); + } + + #[test] + fn selecting_custom_prompt_with_args_expands_placeholders() { + // Support $1..$9 and $ARGUMENTS in prompt content. + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command with two args and hit Enter to submit. + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn numeric_prompt_positional_args_does_not_error() { + // Ensure that a prompt with only numeric placeholders does not trigger + // key=value parsing errors when given positional arguments. + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "elegant".to_string(), + path: "/tmp/elegant.md".to_string().into(), + content: "Echo: $ARGUMENTS".to_string(), + description: None, + argument_hint: None, + }]); + + // Type positional args; should submit with numeric expansion, no errors. + composer.textarea.set_text("/prompts:elegant hi"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn selecting_custom_prompt_with_no_args_inserts_template() { + let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "p".to_string(), + path: "/tmp/p.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // With no args typed, selecting the prompt inserts the command template + // and does not submit immediately. + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:p ", composer.textarea.text()); + } + + #[test] + fn selecting_custom_prompt_preserves_literal_dollar_dollar() { + // '$$' should remain untouched. + let prompt_text = "Cost: $$ and first: $1"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "price".to_string(), + path: "/tmp/price.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Cost: $$ and first: x".to_string()), + result + ); + } + + #[test] + fn selecting_custom_prompt_reuses_cached_arguments_join() { + let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "repeat".to_string(), + path: "/tmp/repeat.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', + 'o', 'n', 'e', ' ', 't', 'w', 'o', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "First: one two\nSecond: one two".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn burst_paste_fast_small_buffers_and_flushes_on_stop() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let count = 32; + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!( + composer.is_in_paste_burst(), + "expected active paste burst during fast typing" + ); + assert!( + composer.textarea.text().is_empty(), + "text should not appear during burst" + ); + } + + assert!( + composer.textarea.text().is_empty(), + "text should remain empty until flush" + ); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected buffered text to flush after stop"); + assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert!( + composer.pending_pastes.is_empty(), + "no placeholder for small burst" + ); + } + + #[test] + fn burst_paste_fast_large_inserts_placeholder_on_flush() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + } + + // Nothing should appear until we stop and flush + assert!(composer.textarea.text().is_empty()); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected flush after stopping fast input"); + + let expected_placeholder = format!("[Pasted Content {count} chars]"); + assert_eq!(composer.textarea.text(), expected_placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.pending_pastes[0].1.len(), count); + assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + } + + #[test] + fn humanlike_typing_1000_chars_appears_live_no_placeholder() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config + let chars: Vec = vec!['z'; count]; + type_chars_humanlike(&mut composer, &chars); + + assert_eq!(composer.textarea.text(), "z".repeat(count)); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn slash_popup_not_activated_for_slash_space_text_history_like_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Simulate history-like content: "/ test" + composer.set_text_content("/ test".to_string()); + + // After set_text_content -> sync_popups is called; popup should NOT be Command. + assert!( + matches!(composer.active_popup, ActivePopup::None), + "expected no slash popup for '/ test'" + ); + + // Up should be handled by history navigation path, not slash popup handler. + let (result, _redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + } + + #[test] + fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { + // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use tokio::sync::mpsc::unbounded_channel; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Case 1: bare "/" + composer.set_text_content("/".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "bare '/' should activate slash popup" + ); + + // Case 2: valid prefix "/re" (matches /review, /resume, etc.) + composer.set_text_content("/re".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::Command(_)), + "'/re' should activate slash popup via prefix match" + ); + + // Case 3: invalid prefix "/zzz" – still allowed to open popup if it + // matches no built-in command, our current logic will *not* open popup. + // Verify that explicitly. + composer.set_text_content("/zzz".to_string()); + assert!( + matches!(composer.active_popup, ActivePopup::None), + "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs new file mode 100644 index 0000000000..991283a566 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs @@ -0,0 +1,300 @@ +use std::collections::HashMap; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_core::protocol::Op; + +/// State machine that manages shell-style history navigation (Up/Down) inside +/// the chat composer. This struct is intentionally decoupled from the +/// rendering widget so the logic remains isolated and easier to test. +pub(crate) struct ChatComposerHistory { + /// Identifier of the history log as reported by `SessionConfiguredEvent`. + history_log_id: Option, + /// Number of entries already present in the persistent cross-session + /// history file when the session started. + history_entry_count: usize, + + /// Messages submitted by the user *during this UI session* (newest at END). + local_history: Vec, + + /// Cache of persistent history entries fetched on-demand. + fetched_history: HashMap, + + /// Current cursor within the combined (persistent + local) history. `None` + /// indicates the user is *not* currently browsing history. + history_cursor: Option, + + /// The text that was last inserted into the composer as a result of + /// history navigation. Used to decide if further Up/Down presses should be + /// treated as navigation versus normal cursor movement. + last_history_text: Option, +} + +impl ChatComposerHistory { + pub fn new() -> Self { + Self { + history_log_id: None, + history_entry_count: 0, + local_history: Vec::new(), + fetched_history: HashMap::new(), + history_cursor: None, + last_history_text: None, + } + } + + /// Update metadata when a new session is configured. + pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { + self.history_log_id = Some(log_id); + self.history_entry_count = entry_count; + self.fetched_history.clear(); + self.local_history.clear(); + self.history_cursor = None; + self.last_history_text = None; + } + + /// Record a message submitted by the user in the current session so it can + /// be recalled later. + pub fn record_local_submission(&mut self, text: &str) { + if text.is_empty() { + return; + } + + self.history_cursor = None; + self.last_history_text = None; + + // Avoid inserting a duplicate if identical to the previous entry. + if self.local_history.last().is_some_and(|prev| prev == text) { + return; + } + + self.local_history.push(text.to_string()); + } + + /// Reset navigation tracking so the next Up key resumes from the latest entry. + pub fn reset_navigation(&mut self) { + self.history_cursor = None; + self.last_history_text = None; + } + + /// Should Up/Down key presses be interpreted as history navigation given + /// the current content and cursor position of `textarea`? + pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { + if self.history_entry_count == 0 && self.local_history.is_empty() { + return false; + } + + if text.is_empty() { + return true; + } + + // Textarea is not empty – only navigate when cursor is at start and + // text matches last recalled history entry so regular editing is not + // hijacked. + if cursor != 0 { + return false; + } + + matches!(&self.last_history_text, Some(prev) if prev == text) + } + + /// Handle . Returns true when the key was consumed and the caller + /// should request a redraw. + pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { + let total_entries = self.history_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx = match self.history_cursor { + None => (total_entries as isize) - 1, + Some(0) => return None, // already at oldest + Some(idx) => idx - 1, + }; + + self.history_cursor = Some(next_idx); + self.populate_history_at_index(next_idx as usize, app_event_tx) + } + + /// Handle . + pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { + let total_entries = self.history_entry_count + self.local_history.len(); + if total_entries == 0 { + return None; + } + + let next_idx_opt = match self.history_cursor { + None => return None, // not browsing + Some(idx) if (idx as usize) + 1 >= total_entries => None, + Some(idx) => Some(idx + 1), + }; + + match next_idx_opt { + Some(idx) => { + self.history_cursor = Some(idx); + self.populate_history_at_index(idx as usize, app_event_tx) + } + None => { + // Past newest – clear and exit browsing mode. + self.history_cursor = None; + self.last_history_text = None; + Some(String::new()) + } + } + } + + /// Integrate a GetHistoryEntryResponse event. + pub fn on_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) -> Option { + if self.history_log_id != Some(log_id) { + return None; + } + let text = entry?; + self.fetched_history.insert(offset, text.clone()); + + if self.history_cursor == Some(offset as isize) { + self.last_history_text = Some(text.clone()); + return Some(text); + } + None + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + fn populate_history_at_index( + &mut self, + global_idx: usize, + app_event_tx: &AppEventSender, + ) -> Option { + if global_idx >= self.history_entry_count { + // Local entry. + if let Some(text) = self + .local_history + .get(global_idx - self.history_entry_count) + { + self.last_history_text = Some(text.clone()); + return Some(text.clone()); + } + } else if let Some(text) = self.fetched_history.get(&global_idx) { + self.last_history_text = Some(text.clone()); + return Some(text.clone()); + } else if let Some(log_id) = self.history_log_id { + let op = Op::GetHistoryEntryRequest { + offset: global_idx, + log_id, + }; + app_event_tx.send(AppEvent::CodexOp(op)); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use codex_core::protocol::Op; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn duplicate_submissions_are_not_recorded() { + let mut history = ChatComposerHistory::new(); + + // Empty submissions are ignored. + history.record_local_submission(""); + assert_eq!(history.local_history.len(), 0); + + // First entry is recorded. + history.record_local_submission("hello"); + assert_eq!(history.local_history.len(), 1); + assert_eq!(history.local_history.last().unwrap(), "hello"); + + // Identical consecutive entry is skipped. + history.record_local_submission("hello"); + assert_eq!(history.local_history.len(), 1); + + // Different entry is recorded. + history.record_local_submission("world"); + assert_eq!(history.local_history.len(), 2); + assert_eq!(history.local_history.last().unwrap(), "world"); + } + + #[test] + fn navigation_with_async_fetch() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + // Pretend there are 3 persistent entries. + history.set_metadata(1, 3); + + // First Up should request offset 2 (latest) and await async data. + assert!(history.should_handle_navigation("", 0)); + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. + let event = rx.try_recv().expect("expected AppEvent to be sent"); + let AppEvent::CodexOp(history_request1) = event else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 2 + }, + history_request1 + ); + + // Inject the async response. + assert_eq!( + Some("latest".into()), + history.on_entry_response(1, 2, Some("latest".into())) + ); + + // Next Up should move to offset 1. + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet + + // Verify second CodexOp event for offset 1. + let event2 = rx.try_recv().expect("expected second event"); + let AppEvent::CodexOp(history_request_2) = event2 else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 1 + }, + history_request_2 + ); + + assert_eq!( + Some("older".into()), + history.on_entry_response(1, 1, Some("older".into())) + ); + } + + #[test] + fn reset_navigation_resets_cursor() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(1, 3); + history.fetched_history.insert(1, "command2".into()); + history.fetched_history.insert(2, "command3".into()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + assert_eq!(Some("command2".into()), history.navigate_up(&tx)); + + history.reset_navigation(); + assert!(history.history_cursor.is_none()); + assert!(history.last_history_text.is_none()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs new file mode 100644 index 0000000000..8aca5c4a62 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -0,0 +1,376 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; +use codex_common::fuzzy_match::fuzzy_match; +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use std::collections::HashSet; + +/// A selectable item in the popup: either a built-in command or a user prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandItem { + Builtin(SlashCommand), + // Index into `prompts` + UserPrompt(usize), +} + +pub(crate) struct CommandPopup { + command_filter: String, + builtins: Vec<(&'static str, SlashCommand)>, + prompts: Vec, + state: ScrollState, +} + +impl CommandPopup { + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .collect(); + // Exclude prompts that collide with builtin command names and sort by name. + let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + Self { + command_filter: String::new(), + builtins, + prompts, + state: ScrollState::new(), + } + } + + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: HashSet = self + .builtins + .iter() + .map(|(n, _)| (*n).to_string()) + .collect(); + prompts.retain(|p| !exclude.contains(&p.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.prompts = prompts; + } + + pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { + self.prompts.get(idx) + } + + /// Update the filter string based on the current composer text. The text + /// passed in is expected to start with a leading '/'. Everything after the + /// *first* '/" on the *first* line becomes the active filter that is used + /// to narrow down the list of available commands. + pub(crate) fn on_composer_text_change(&mut self, text: String) { + let first_line = text.lines().next().unwrap_or(""); + + if let Some(stripped) = first_line.strip_prefix('/') { + // Extract the *first* token (sequence of non-whitespace + // characters) after the slash so that `/clear something` still + // shows the help for `/clear`. + let token = stripped.trim_start(); + let cmd_token = token.split_whitespace().next().unwrap_or(""); + + // Update the filter keeping the original case (commands are all + // lower-case for now but this may change in the future). + self.command_filter = cmd_token.to_string(); + } else { + // The composer no longer starts with '/'. Reset the filter so the + // popup shows the *full* command list if it is still displayed + // for some reason. + self.command_filter.clear(); + } + + // Reset or clamp selected index based on new filtered list. + let matches_len = self.filtered_items().len(); + self.state.clamp_selection(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// Determine the preferred height of the popup for a given width. + /// Accounts for wrapped descriptions so that long tooltips don't overflow. + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + use super::selection_popup_common::measure_rows_height; + let rows = self.rows_from_matches(self.filtered()); + + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + /// Compute fuzzy-filtered matches over built-in commands and user prompts, + /// paired with optional highlight indices and score. Sorted by ascending + /// score, then by name for stability. + fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { + let filter = self.command_filter.trim(); + let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); + if filter.is_empty() { + // Built-ins first, in presentation order. + for (_, cmd) in self.builtins.iter() { + out.push((CommandItem::Builtin(*cmd), None, 0)); + } + // Then prompts, already sorted by name. + for idx in 0..self.prompts.len() { + out.push((CommandItem::UserPrompt(idx), None, 0)); + } + return out; + } + + for (_, cmd) in self.builtins.iter() { + if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { + out.push((CommandItem::Builtin(*cmd), Some(indices), score)); + } + } + // Support both search styles: + // - Typing "name" should surface "/prompts:name" results. + // - Typing "prompts:name" should also work. + for (idx, p) in self.prompts.iter().enumerate() { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); + if let Some((indices, score)) = fuzzy_match(&display, filter) { + out.push((CommandItem::UserPrompt(idx), Some(indices), score)); + } + } + // When filtering, sort by ascending score and then by name for stability. + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = match a.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + let bn = match b.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + an.cmp(bn) + }) + }); + out + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(c, _, _)| c).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(CommandItem, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(item, indices, _)| { + let (name, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::UserPrompt(i) => { + let prompt = &self.prompts[i]; + let description = prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()); + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + description, + ) + } + }; + GenericDisplayRow { + name, + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + display_shortcut: None, + description: Some(description), + wrap_indent: None, + } + }) + .collect() + } + + /// Move the selection cursor one step up. + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + /// Move the selection cursor one step down. + pub(crate) fn move_down(&mut self) { + let matches_len = self.filtered_items().len(); + self.state.move_down_wrap(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); + } + + /// Return currently selected command, if any. + pub(crate) fn selected_item(&self) -> Option { + let matches = self.filtered_items(); + self.state + .selected_idx + .and_then(|idx| matches.get(idx).copied()) + } +} + +impl WidgetRef for CommandPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no matches", + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn filter_includes_init_when_typing_prefix() { + let mut popup = CommandPopup::new(Vec::new(), false); + // Simulate the composer line starting with '/in' so the popup filters + // matching commands by prefix. + popup.on_composer_text_change("/in".to_string()); + + // Access the filtered list via the selected command and ensure that + // one of the matches is the new "init" command. + let matches = popup.filtered_items(); + let has_init = matches.iter().any(|item| match item { + CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::UserPrompt(_) => false, + }); + assert!( + has_init, + "expected '/init' to appear among filtered commands" + ); + } + + #[test] + fn selecting_init_by_exact_match() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/init".to_string()); + + // When an exact match exists, the selected command should be that + // command by default. + let selected = popup.selected_item(); + match selected { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), + None => panic!("expected a selected command for exact match"), + } + } + + #[test] + fn model_is_first_suggestion_for_mo() { + let mut popup = CommandPopup::new(Vec::new(), false); + popup.on_composer_text_change("/mo".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/model' for '/mo'") + } + None => panic!("expected at least one match for '/mo'"), + } + } + + #[test] + fn prompt_discovery_lists_custom_prompts() { + let prompts = vec![ + CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "hello from foo".to_string(), + description: None, + argument_hint: None, + }, + CustomPrompt { + name: "bar".to_string(), + path: "/tmp/bar.md".to_string().into(), + content: "hello from bar".to_string(), + description: None, + argument_hint: None, + }, + ]; + let popup = CommandPopup::new(prompts, false); + let items = popup.filtered_items(); + let mut prompt_names: Vec = items + .into_iter() + .filter_map(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), + _ => None, + }) + .collect(); + prompt_names.sort(); + assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); + } + + #[test] + fn prompt_name_collision_with_builtin_is_ignored() { + // Create a prompt named like a builtin (e.g. "init"). + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + false, + ); + let items = popup.filtered_items(); + let has_collision_prompt = items.into_iter().any(|it| match it { + CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), + _ => false, + }); + assert!( + !has_collision_prompt, + "prompt with builtin name should be ignored" + ); + } + + #[test] + fn prompt_description_uses_frontmatter_metadata() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }], + false, + ); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!( + description, + Some("Create feature branch, commit and open draft PR.") + ); + } + + #[test] + fn prompt_description_falls_back_when_missing() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }], + false, + ); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!(description, Some("send saved prompt")); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 0000000000..e9f0ee697f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs @@ -0,0 +1,247 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::cell::RefCell; + +use crate::render::renderable::Renderable; + +use super::popup_consts::standard_popup_hint_line; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +/// Callback invoked when the user submits a custom prompt. +pub(crate) type PromptSubmitted = Box; + +/// Minimal multi-line text input view to collect custom review instructions. +pub(crate) struct CustomPromptView { + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl CustomPromptView { + pub(crate) fn new( + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + ) -> Self { + Self { + title, + placeholder, + context_label, + on_submit, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } + } +} + +impl BottomPaneView for CustomPromptView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let text = self.textarea.text().trim().to_string(); + if !text.is_empty() { + (self.on_submit)(text); + self.complete = true; + } + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.textarea.input(key_event); + } + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for CustomPromptView { + fn desired_height(&self, width: u16) -> u16 { + let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; + 1u16 + extra_top + self.input_height(width) + 3u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let input_height = self.input_height(area.width); + + // Title line + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + let title_spans: Vec> = vec![gutter(), self.title.clone().bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Optional context line + let mut input_y = area.y.saturating_add(1); + if let Some(context_label) = &self.context_label { + let context_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: 1, + }; + let spans: Vec> = vec![gutter(), context_label.clone().cyan()]; + Paragraph::new(Line::from(spans)).render(context_area, buf); + input_y = input_y.saturating_add(1); + } + + // Input line + let input_area = Rect { + x: area.x, + y: input_y, + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(self.placeholder.clone().dim())) + .render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(standard_popup_hint_line()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; + let top_line_count = 1u16 + extra_offset; + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } +} + +impl CustomPromptView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} diff --git a/codex-rs/tui2/src/bottom_pane/feedback_view.rs b/codex-rs/tui2/src/bottom_pane/feedback_view.rs new file mode 100644 index 0000000000..c563ab8e90 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/feedback_view.rs @@ -0,0 +1,559 @@ +use std::cell::RefCell; +use std::path::PathBuf; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event::FeedbackCategory; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::render::renderable::Renderable; +use codex_core::protocol::SessionSource; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::standard_popup_hint_line; +use super::textarea::TextArea; +use super::textarea::TextAreaState; + +const BASE_BUG_ISSUE_URL: &str = + "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +/// Minimal input overlay to collect an optional feedback note, then upload +/// both logs and rollout with classification + metadata. +pub(crate) struct FeedbackNoteView { + category: FeedbackCategory, + snapshot: codex_feedback::CodexLogSnapshot, + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl FeedbackNoteView { + pub(crate) fn new( + category: FeedbackCategory, + snapshot: codex_feedback::CodexLogSnapshot, + rollout_path: Option, + app_event_tx: AppEventSender, + include_logs: bool, + ) -> Self { + Self { + category, + snapshot, + rollout_path, + app_event_tx, + include_logs, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + } + } + + fn submit(&mut self) { + let note = self.textarea.text().trim().to_string(); + let reason_opt = if note.is_empty() { + None + } else { + Some(note.as_str()) + }; + let rollout_path_ref = self.rollout_path.as_deref(); + let classification = feedback_classification(self.category); + + let mut thread_id = self.snapshot.thread_id.clone(); + + let result = self.snapshot.upload_feedback( + classification, + reason_opt, + self.include_logs, + if self.include_logs { + rollout_path_ref + } else { + None + }, + Some(SessionSource::Cli), + ); + + match result { + Ok(()) => { + let prefix = if self.include_logs { + "• Feedback uploaded." + } else { + "• Feedback recorded (no logs)." + }; + let issue_url = issue_url_for_category(self.category, &thread_id); + let mut lines = vec![Line::from(match issue_url.as_ref() { + Some(_) => format!("{prefix} Please open an issue using the following URL:"), + None => format!("{prefix} Thanks for the feedback!"), + })]; + if let Some(url) = issue_url { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + std::mem::take(&mut thread_id).bold(), + " in an existing issue.".into(), + ]), + ]); + } else { + lines.extend([ + "".into(), + Line::from(vec![ + " Thread ID: ".into(), + std::mem::take(&mut thread_id).bold(), + ]), + ]); + } + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::PlainHistoryCell::new(lines), + ))); + } + Err(e) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload feedback: {e}")), + ))); + } + } + self.complete = true; + } +} + +impl BottomPaneView for FeedbackNoteView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + self.submit(); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.textarea.input(key_event); + } + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + 1u16 + self.input_height(width) + 3u16 + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let top_line_count = 1u16; // title only + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let (title, placeholder) = feedback_title_and_placeholder(self.category); + let input_height = self.input_height(area.width); + + // Title line + let title_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }; + let title_spans: Vec> = vec![gutter(), title.bold()]; + Paragraph::new(Line::from(title_spans)).render(title_area, buf); + + // Input line + let input_area = Rect { + x: area.x, + y: area.y.saturating_add(1), + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(standard_popup_hint_line()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } +} + +impl FeedbackNoteView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); + text_height.saturating_add(1).min(9) + } +} + +fn gutter() -> Span<'static> { + "▌ ".cyan() +} + +fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { + match category { + FeedbackCategory::BadResult => ( + "Tell us more (bad result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::GoodResult => ( + "Tell us more (good result)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Bug => ( + "Tell us more (bug)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + FeedbackCategory::Other => ( + "Tell us more (other)".to_string(), + "(optional) Write a short description to help us further".to_string(), + ), + } +} + +fn feedback_classification(category: FeedbackCategory) -> &'static str { + match category { + FeedbackCategory::BadResult => "bad_result", + FeedbackCategory::GoodResult => "good_result", + FeedbackCategory::Bug => "bug", + FeedbackCategory::Other => "other", + } +} + +fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { + match category { + FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), + ), + FeedbackCategory::GoodResult => None, + } +} + +// Build the selection popup params for feedback categories. +pub(crate) fn feedback_selection_params( + app_event_tx: AppEventSender, +) -> super::SelectionViewParams { + super::SelectionViewParams { + title: Some("How was this?".to_string()), + items: vec![ + make_feedback_item( + app_event_tx.clone(), + "bug", + "Crash, error message, hang, or broken UI/behavior.", + FeedbackCategory::Bug, + ), + make_feedback_item( + app_event_tx.clone(), + "bad result", + "Output was off-target, incorrect, incomplete, or unhelpful.", + FeedbackCategory::BadResult, + ), + make_feedback_item( + app_event_tx.clone(), + "good result", + "Helpful, correct, high‑quality, or delightful result worth celebrating.", + FeedbackCategory::GoodResult, + ), + make_feedback_item( + app_event_tx, + "other", + "Slowness, feature suggestion, UX feedback, or anything else.", + FeedbackCategory::Other, + ), + ], + ..Default::default() + } +} + +fn make_feedback_item( + app_event_tx: AppEventSender, + name: &str, + description: &str, + category: FeedbackCategory, +) -> super::SelectionItem { + let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { + app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + }); + super::SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![action], + dismiss_on_select: true, + ..Default::default() + } +} + +/// Build the upload consent popup params for a given feedback category. +pub(crate) fn feedback_upload_consent_params( + app_event_tx: AppEventSender, + category: FeedbackCategory, + rollout_path: Option, +) -> super::SelectionViewParams { + use super::popup_consts::standard_popup_hint_line; + let yes_action: super::SelectionAction = Box::new({ + let tx = app_event_tx.clone(); + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: true, + }); + } + }); + + let no_action: super::SelectionAction = Box::new({ + let tx = app_event_tx; + move |sender: &AppEventSender| { + let _ = sender; + tx.send(AppEvent::OpenFeedbackNote { + category, + include_logs: false, + }); + } + }); + + // Build header listing files that would be sent if user consents. + let mut header_lines: Vec> = vec![ + Line::from("Upload logs?".bold()).into(), + Line::from("").into(), + Line::from("The following files will be sent:".dim()).into(), + Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + ]; + if let Some(path) = rollout_path.as_deref() + && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) + { + header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); + } + + super::SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + super::SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![yes_action], + dismiss_on_select: true, + ..Default::default() + }, + super::SelectionItem { + name: "No".to_string(), + description: Some("".to_string()), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }, + ], + header: Box::new(crate::render::renderable::ColumnRenderable::with( + header_lines, + )), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + + fn render(view: &FeedbackNoteView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let mut lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + fn make_view(category: FeedbackCategory) -> FeedbackNoteView { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); + FeedbackNoteView::new(category, snapshot, None, tx, true) + } + + #[test] + fn feedback_view_bad_result() { + let view = make_view(FeedbackCategory::BadResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bad_result", rendered); + } + + #[test] + fn feedback_view_good_result() { + let view = make_view(FeedbackCategory::GoodResult); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_good_result", rendered); + } + + #[test] + fn feedback_view_bug() { + let view = make_view(FeedbackCategory::Bug); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_bug", rendered); + } + + #[test] + fn feedback_view_other() { + let view = make_view(FeedbackCategory::Other); + let rendered = render(&view, 60); + insta::assert_snapshot!("feedback_view_other", rendered); + } + + #[test] + fn issue_url_available_for_bug_bad_result_and_other() { + let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); + assert!( + bug_url + .as_deref() + .is_some_and(|url| url.contains("template=2-bug-report")) + ); + + let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); + assert!(bad_result_url.is_some()); + + let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); + assert!(other_url.is_some()); + + assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs new file mode 100644 index 0000000000..064e4f0137 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,154 @@ +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use crate::render::Insets; +use crate::render::RectExt; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; + +/// Visual state for the file-search popup. +pub(crate) struct FileSearchPopup { + /// Query corresponding to the `matches` currently shown. + display_query: String, + /// Latest query typed by the user. May differ from `display_query` when + /// a search is still in-flight. + pending_query: String, + /// When `true` we are still waiting for results for `pending_query`. + waiting: bool, + /// Cached matches; paths relative to the search dir. + matches: Vec, + /// Shared selection/scroll state. + state: ScrollState, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + display_query: String::new(), + pending_query: String::new(), + waiting: true, + matches: Vec::new(), + state: ScrollState::new(), + } + } + + /// Update the query and reset state to *waiting*. + pub(crate) fn set_query(&mut self, query: &str) { + if query == self.pending_query { + return; + } + + // Determine if current matches are still relevant. + let keep_existing = query.starts_with(&self.display_query); + + self.pending_query.clear(); + self.pending_query.push_str(query); + + self.waiting = true; // waiting for new results + + if !keep_existing { + self.matches.clear(); + self.state.reset(); + } + } + + /// Put the popup into an "idle" state used for an empty query (just "@"). + /// Shows a hint instead of matches until the user types more characters. + pub(crate) fn set_empty_prompt(&mut self) { + self.display_query.clear(); + self.pending_query.clear(); + self.waiting = false; + self.matches.clear(); + // Reset selection/scroll state when showing the empty prompt. + self.state.reset(); + } + + /// Replace matches when a `FileSearchResult` arrives. + /// Replace matches. Only applied when `query` matches `pending_query`. + pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; // stale + } + + self.display_query = query.to_string(); + self.matches = matches; + self.waiting = false; + let len = self.matches.len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + let len = self.matches.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + let len = self.matches.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); + } + + pub(crate) fn selected_match(&self) -> Option<&str> { + self.state + .selected_idx + .and_then(|idx| self.matches.get(idx)) + .map(|file_match| file_match.path.as_str()) + } + + pub(crate) fn calculate_required_height(&self) -> u16 { + // Row count depends on whether we already have matches. If no matches + // yet (e.g. initial search or query with no results) reserve a single + // row so the popup is still visible. When matches are present we show + // up to MAX_RESULTS regardless of the waiting flag so the list + // remains stable while a newer search is in-flight. + + self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 + } +} + +impl WidgetRef for &FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. + let rows_all: Vec = if self.matches.is_empty() { + Vec::new() + } else { + self.matches + .iter() + .map(|m| GenericDisplayRow { + name: m.path.clone(), + match_indices: m + .indices + .as_ref() + .map(|v| v.iter().map(|&i| i as usize).collect()), + display_shortcut: None, + description: None, + wrap_indent: None, + }) + .collect() + }; + + let empty_message = if self.waiting { + "loading..." + } else { + "no matches" + }; + + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + empty_message, + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs new file mode 100644 index 0000000000..d47ffec98b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -0,0 +1,530 @@ +#[cfg(target_os = "linux")] +use crate::clipboard_paste::is_probably_wsl; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::render::line_utils::prefix_lines; +use crate::status::format_tokens_compact; +use crate::ui_consts::FOOTER_INDENT_COLS; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, + pub(crate) esc_backtrack_hint: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) is_task_running: bool, + pub(crate) context_window_percent: Option, + pub(crate) context_window_used_tokens: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutSummary, + ShortcutOverlay, + EscHint, + ContextOnly, +} + +pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { + if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) { + return current; + } + + match current { + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary, + _ => FooterMode::ShortcutOverlay, + } +} + +pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { + if is_task_running { + current + } else { + FooterMode::EscHint + } +} + +pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { + match current { + FooterMode::EscHint + | FooterMode::ShortcutOverlay + | FooterMode::CtrlCReminder + | FooterMode::ContextOnly => FooterMode::ShortcutSummary, + other => other, + } +} + +pub(crate) fn footer_height(props: FooterProps) -> u16 { + footer_lines(props).len() as u16 +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + Paragraph::new(prefix_lines( + footer_lines(props), + " ".repeat(FOOTER_INDENT_COLS).into(), + " ".repeat(FOOTER_INDENT_COLS).into(), + )) + .render(area, buf); +} + +fn footer_lines(props: FooterProps) -> Vec> { + // Show the context indicator on the left, appended after the primary hint + // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when + // the shortcut hint is hidden). Hide it only for the multi-line + // ShortcutOverlay. + match props.mode { + FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { + is_task_running: props.is_task_running, + })], + FooterMode::ShortcutSummary => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + line.push_span(" · ".dim()); + line.extend(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ]); + vec![line] + } + FooterMode::ShortcutOverlay => { + #[cfg(target_os = "linux")] + let is_wsl = is_probably_wsl(); + #[cfg(not(target_os = "linux"))] + let is_wsl = false; + + let state = ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_wsl, + }; + shortcut_overlay_lines(state) + } + FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::ContextOnly => vec![context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + )], + } +} + +#[derive(Clone, Copy, Debug)] +struct CtrlCReminderState { + is_task_running: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_wsl: bool, +} + +fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { + let action = if state.is_task_running { + "interrupt" + } else { + "quit" + }; + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + format!(" again to {action}").into(), + ]) + .dim() +} + +fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { + let esc = key_hint::plain(KeyCode::Esc); + if esc_backtrack_hint { + Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() + } else { + Line::from(vec![ + esc.into(), + " ".into(), + esc.into(), + " to edit previous message".into(), + ]) + .dim() + } +} + +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut commands = Line::from(""); + let mut newline = Line::from(""); + let mut file_paths = Line::from(""); + let mut paste_image = Line::from(""); + let mut edit_previous = Line::from(""); + let mut quit = Line::from(""); + let mut show_transcript = Line::from(""); + + for descriptor in SHORTCUTS { + if let Some(text) = descriptor.overlay_entry(state) { + match descriptor.id { + ShortcutId::Commands => commands = text, + ShortcutId::InsertNewline => newline = text, + ShortcutId::FilePaths => file_paths = text, + ShortcutId::PasteImage => paste_image = text, + ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::Quit => quit = text, + ShortcutId::ShowTranscript => show_transcript = text, + } + } + } + + let ordered = vec![ + commands, + newline, + file_paths, + paste_image, + edit_previous, + quit, + Line::from(""), + show_transcript, + ]; + + build_columns(ordered) +} + +fn build_columns(entries: Vec>) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 2; + const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; + const COLUMN_GAP: usize = 4; + + let rows = entries.len().div_ceil(COLUMNS); + let target_len = rows * COLUMNS; + let mut entries = entries; + if entries.len() < target_len { + entries.extend(std::iter::repeat_n( + Line::from(""), + target_len - entries.len(), + )); + } + + let mut column_widths = [0usize; COLUMNS]; + + for (idx, entry) in entries.iter().enumerate() { + let column = idx % COLUMNS; + column_widths[column] = column_widths[column].max(entry.width()); + } + + for (idx, width) in column_widths.iter_mut().enumerate() { + *width += COLUMN_PADDING[idx]; + } + + entries + .chunks(COLUMNS) + .map(|chunk| { + let mut line = Line::from(""); + for (col, entry) in chunk.iter().enumerate() { + line.extend(entry.spans.clone()); + if col < COLUMNS - 1 { + let target_width = column_widths[col]; + let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; + line.push_span(Span::from(" ".repeat(padding))); + } + } + line.dim() + }) + .collect() +} + +fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { + if let Some(percent) = percent { + let percent = percent.clamp(0, 100); + return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); + } + + if let Some(tokens) = used_tokens { + let used_fmt = format_tokens_compact(tokens); + return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); + } + + Line::from(vec![Span::from("100% context left").dim()]) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutId { + Commands, + InsertNewline, + FilePaths, + PasteImage, + EditPrevious, + Quit, + ShowTranscript, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ShortcutBinding { + key: KeyBinding, + condition: DisplayCondition, +} + +impl ShortcutBinding { + fn matches(&self, state: ShortcutsState) -> bool { + self.condition.matches(state) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayCondition { + Always, + WhenShiftEnterHint, + WhenNotShiftEnterHint, + WhenUnderWSL, +} + +impl DisplayCondition { + fn matches(self, state: ShortcutsState) -> bool { + match self { + DisplayCondition::Always => true, + DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, + DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + DisplayCondition::WhenUnderWSL => state.is_wsl, + } + } +} + +struct ShortcutDescriptor { + id: ShortcutId, + bindings: &'static [ShortcutBinding], + prefix: &'static str, + label: &'static str, +} + +impl ShortcutDescriptor { + fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { + self.bindings.iter().find(|binding| binding.matches(state)) + } + + fn overlay_entry(&self, state: ShortcutsState) -> Option> { + let binding = self.binding_for(state)?; + let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + match self.id { + ShortcutId::EditPrevious => { + if state.esc_backtrack_hint { + line.push_span(" again to edit previous message"); + } else { + line.extend(vec![ + " ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to edit previous message".into(), + ]); + } + } + _ => line.push_span(self.label), + }; + Some(line) + } +} + +const SHORTCUTS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + id: ShortcutId::Commands, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('/')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for commands", + }, + ShortcutDescriptor { + id: ShortcutId::InsertNewline, + bindings: &[ + ShortcutBinding { + key: key_hint::shift(KeyCode::Enter), + condition: DisplayCondition::WhenShiftEnterHint, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('j')), + condition: DisplayCondition::WhenNotShiftEnterHint, + }, + ], + prefix: "", + label: " for newline", + }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Char('@')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, + ShortcutDescriptor { + id: ShortcutId::PasteImage, + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain + // Ctrl+V); otherwise fall back to Ctrl+V. + bindings: &[ + ShortcutBinding { + key: key_hint::ctrl_alt(KeyCode::Char('v')), + condition: DisplayCondition::WhenUnderWSL, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('v')), + condition: DisplayCondition::Always, + }, + ], + prefix: "", + label: " to paste images", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Esc), + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('c')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to exit", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('t')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to view transcript", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn snapshot_footer(name: &str, props: FooterProps) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, height); + render_footer(area, f.buffer_mut(), props); + }) + .unwrap(); + assert_snapshot!(name, terminal.backend()); + } + + #[test] + fn footer_snapshots() { + snapshot_footer( + "footer_shortcuts_default", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_shift_and_esc", + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_context_running", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: Some(72), + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_context_tokens_used", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: Some(123_456), + }, + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs new file mode 100644 index 0000000000..d23fd8ed3b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -0,0 +1,794 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use itertools::Itertools as _; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event_sender::AppEventSender; +use crate::key_hint::KeyBinding; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use unicode_width::UnicodeWidthStr; + +/// One selectable item in the generic selection list. +pub(crate) type SelectionAction = Box; + +#[derive(Default)] +pub(crate) struct SelectionItem { + pub name: String, + pub display_shortcut: Option, + pub description: Option, + pub selected_description: Option, + pub is_current: bool, + pub actions: Vec, + pub dismiss_on_select: bool, + pub search_value: Option, +} + +pub(crate) struct SelectionViewParams { + pub title: Option, + pub subtitle: Option, + pub footer_hint: Option>, + pub items: Vec, + pub is_searchable: bool, + pub search_placeholder: Option, + pub header: Box, + pub initial_selected_idx: Option, +} + +impl Default for SelectionViewParams { + fn default() -> Self { + Self { + title: None, + subtitle: None, + footer_hint: None, + items: Vec::new(), + is_searchable: false, + search_placeholder: None, + header: Box::new(()), + initial_selected_idx: None, + } + } +} + +pub(crate) struct ListSelectionView { + footer_hint: Option>, + items: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + is_searchable: bool, + search_query: String, + search_placeholder: Option, + filtered_indices: Vec, + last_selected_actual_idx: Option, + header: Box, + initial_selected_idx: Option, +} + +impl ListSelectionView { + pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { + let mut header = params.header; + if params.title.is_some() || params.subtitle.is_some() { + let title = params.title.map(|title| Line::from(title.bold())); + let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); + header = Box::new(ColumnRenderable::with([ + header, + Box::new(title), + Box::new(subtitle), + ])); + } + let mut s = Self { + footer_hint: params.footer_hint, + items: params.items, + state: ScrollState::new(), + complete: false, + app_event_tx, + is_searchable: params.is_searchable, + search_query: String::new(), + search_placeholder: if params.is_searchable { + params.search_placeholder + } else { + None + }, + filtered_indices: Vec::new(), + last_selected_actual_idx: None, + header, + initial_selected_idx: params.initial_selected_idx, + }; + s.apply_filter(); + s + } + + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + fn apply_filter(&mut self) { + let previously_selected = self + .state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) + .or_else(|| { + (!self.is_searchable) + .then(|| self.items.iter().position(|item| item.is_current)) + .flatten() + }) + .or_else(|| self.initial_selected_idx.take()); + + if self.is_searchable && !self.search_query.is_empty() { + let query_lower = self.search_query.to_lowercase(); + self.filtered_indices = self + .items + .iter() + .positions(|item| { + item.search_value + .as_ref() + .is_some_and(|v| v.to_lowercase().contains(&query_lower)) + }) + .collect(); + } else { + self.filtered_indices = (0..self.items.len()).collect(); + } + + let len = self.filtered_indices.len(); + self.state.selected_idx = self + .state + .selected_idx + .and_then(|visible_idx| { + self.filtered_indices + .get(visible_idx) + .and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx)) + }) + .or_else(|| { + previously_selected.and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + }) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + } + + fn build_rows(&self) -> Vec { + self.filtered_indices + .iter() + .enumerate() + .filter_map(|(visible_idx, actual_idx)| { + self.items.get(*actual_idx).map(|item| { + let is_selected = self.state.selected_idx == Some(visible_idx); + let prefix = if is_selected { '›' } else { ' ' }; + let name = item.name.as_str(); + let name_with_marker = if item.is_current { + format!("{name} (current)") + } else { + item.name.clone() + }; + let n = visible_idx + 1; + let wrap_prefix = if self.is_searchable { + // The number keys don't work when search is enabled (since we let the + // numbers be used for the search query). + format!("{prefix} ") + } else { + format!("{prefix} {n}. ") + }; + let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); + let display_name = format!("{wrap_prefix}{name_with_marker}"); + let description = is_selected + .then(|| item.selected_description.clone()) + .flatten() + .or_else(|| item.description.clone()); + let wrap_indent = description.is_none().then_some(wrap_prefix_width); + GenericDisplayRow { + name: display_name, + display_shortcut: item.display_shortcut, + match_indices: None, + description, + wrap_indent, + } + }) + }) + .collect() + } + + fn move_up(&mut self) { + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + self.state.move_down_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + fn accept(&mut self) { + if let Some(idx) = self.state.selected_idx + && let Some(actual_idx) = self.filtered_indices.get(idx) + && let Some(item) = self.items.get(*actual_idx) + { + self.last_selected_actual_idx = Some(*actual_idx); + for act in &item.actions { + act(&self.app_event_tx); + } + if item.dismiss_on_select { + self.complete = true; + } + } else { + self.complete = true; + } + } + + #[cfg(test)] + pub(crate) fn set_search_query(&mut self, query: String) { + self.search_query = query; + self.apply_filter(); + } + + pub(crate) fn take_last_selected_index(&mut self) -> Option { + self.last_selected_actual_idx.take() + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } +} + +impl BottomPaneView for ListSelectionView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^N */ => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } if !self.is_searchable => self.move_down(), + KeyEvent { + code: KeyCode::Backspace, + .. + } if self.is_searchable => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !self.is_searchable + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + if let Some(idx) = c + .to_digit(10) + .map(|d| d as usize) + .and_then(|d| d.checked_sub(1)) + && idx < self.items.len() + { + self.state.selected_idx = Some(idx); + self.accept(); + } + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.accept(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } +} + +impl Renderable for ListSelectionView { + fn desired_height(&self, width: u16) -> u16 { + // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. + // Build the same display rows used by the renderer so wrapping math matches. + let rows = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + // Subtract 4 for the padding on the left and right of the header. + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + if self.is_searchable { + height = height.saturating_add(1); + } + if self.footer_hint.is_some() { + height = height.saturating_add(1); + } + height + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + ]) + .areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + // Subtract 4 for the padding on the left and right of the header. + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, search_area, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(if self.is_searchable { 1 } else { 0 }), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(1, 2))); + + if header_area.height < header_height { + let [header_area, elision_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); + self.header.render(header_area, buf); + Paragraph::new(vec![ + Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), + ]) + .render(elision_area, buf); + } else { + self.header.render(header_area, buf); + } + + if self.is_searchable { + Line::from(self.search_query.clone()).render(search_area, buf); + let query_span: Span<'static> = if self.search_query.is_empty() { + self.search_placeholder + .as_ref() + .map(|placeholder| placeholder.clone().dim()) + .unwrap_or_else(|| "".into()) + } else { + self.search_query.clone().into() + }; + Line::from(query_span).render(search_area, buf); + } + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ); + } + + if let Some(hint) = &self.footer_hint { + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + hint.clone().dim().render(hint_area, buf); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::popup_consts::standard_popup_hint_line; + use insta::assert_snapshot; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Full Access".to_string(), + description: Some("Codex can edit files".to_string()), + is_current: false, + dismiss_on_select: true, + ..Default::default() + }, + ]; + ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + subtitle: subtitle.map(str::to_string), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ) + } + + fn render_lines(view: &ListSelectionView) -> String { + render_lines_with_width(view, 48) + } + + fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect(); + lines.join("\n") + } + + #[test] + fn renders_blank_line_between_title_and_items_without_subtitle() { + let view = make_selection_view(None); + assert_snapshot!( + "list_selection_spacing_without_subtitle", + render_lines(&view) + ); + } + + #[test] + fn renders_blank_line_between_subtitle_and_items() { + let view = make_selection_view(Some("Switch between Codex approval presets")); + assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); + } + + #[test] + fn renders_search_query_line_when_enabled() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: false, + dismiss_on_select: true, + ..Default::default() + }]; + let mut view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }, + tx, + ); + view.set_search_query("filters".to_string()); + + let lines = render_lines(&view); + assert!( + lines.contains("filters"), + "expected search query line to include rendered query, got {lines:?}" + ); + } + + #[test] + fn wraps_long_option_without_overflowing_columns() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "Yes, proceed".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Approval".to_string()), + items, + ..Default::default() + }, + tx, + ); + + let rendered = render_lines_with_width(&view, 60); + let command_line = rendered + .lines() + .find(|line| line.contains("python -mpre_commit run")) + .expect("rendered lines should include wrapped command"); + assert!( + command_line.starts_with(" `python -mpre_commit run"), + "wrapped command line should align under the numbered prefix:\n{rendered}" + ); + assert!( + rendered.contains("eslint-plugin/no-") + && rendered.contains("mixed-const-enum-exports.js"), + "long command should not be truncated even when wrapped:\n{rendered}" + ); + } + + #[test] + fn width_changes_do_not_hide_rows() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + let mut missing: Vec = Vec::new(); + for width in 60..=90 { + let rendered = render_lines_with_width(&view, width); + if !rendered.contains("3.") { + missing.push(width); + } + } + assert!( + missing.is_empty(), + "third option missing at widths {missing:?}" + ); + } + + #[test] + fn narrow_width_keeps_all_rows_visible() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + let rendered = render_lines_with_width(&view, 24); + assert!( + rendered.contains("3."), + "third option missing for width 24:\n{rendered}" + ); + } + + #[test] + fn snapshot_model_picker_width_80() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![ + SelectionItem { + name: "gpt-5.1-codex".to_string(), + description: Some( + "Optimized for Codex. Balance of reasoning quality and coding ability." + .to_string(), + ), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-5.1-codex-mini".to_string(), + description: Some( + "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "gpt-4.1-codex".to_string(), + description: Some( + "Legacy model. Use when you need compatibility with older automations." + .to_string(), + ), + dismiss_on_select: true, + ..Default::default() + }, + ]; + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_model_picker_width_80", + render_lines_with_width(&view, 80) + ); + } + + #[test] + fn snapshot_narrow_width_preserves_third_option() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let desc = "x".repeat(10); + let items: Vec = (1..=3) + .map(|idx| SelectionItem { + name: format!("Item {idx}"), + description: Some(desc.clone()), + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_narrow_width_preserves_rows", + render_lines_with_width(&view, 24) + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs new file mode 100644 index 0000000000..554810de7f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -0,0 +1,814 @@ +//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. +use std::path::PathBuf; + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::queued_user_messages::QueuedUserMessages; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableItem; +use crate::tui::FrameRequester; +use bottom_pane_view::BottomPaneView; +use codex_core::features::Features; +use codex_core::skills::model::SkillMetadata; +use codex_file_search::FileMatch; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::time::Duration; + +mod approval_overlay; +pub(crate) use approval_overlay::ApprovalOverlay; +pub(crate) use approval_overlay::ApprovalRequest; +mod bottom_pane_view; +mod chat_composer; +mod chat_composer_history; +mod command_popup; +pub mod custom_prompt_view; +mod file_search_popup; +mod footer; +mod list_selection_view; +mod prompt_args; +mod skill_popup; +pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; +pub(crate) use feedback_view::feedback_selection_params; +pub(crate) use feedback_view::feedback_upload_consent_params; +mod paste_burst; +pub mod popup_consts; +mod queued_user_messages; +mod scroll_state; +mod selection_popup_common; +mod textarea; +pub(crate) use feedback_view::FeedbackNoteView; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CancellationEvent { + Handled, + NotHandled, +} + +pub(crate) use chat_composer::ChatComposer; +pub(crate) use chat_composer::InputResult; +use codex_protocol::custom_prompts::CustomPrompt; + +use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use list_selection_view::SelectionAction; +pub(crate) use list_selection_view::SelectionItem; + +/// Pane displayed in the lower half of the chat UI. +pub(crate) struct BottomPane { + /// Composer is retained even when a BottomPaneView is displayed so the + /// input state is retained when the view is closed. + composer: ChatComposer, + + /// Stack of views displayed instead of the composer (e.g. popups/modals). + view_stack: Vec>, + + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + + has_input_focus: bool, + is_task_running: bool, + ctrl_c_quit_hint: bool, + esc_backtrack_hint: bool, + animations_enabled: bool, + + /// Inline status indicator shown above the composer while a task is running. + status: Option, + /// Queued user messages to show above the composer while a turn is running. + queued_user_messages: QueuedUserMessages, + context_window_percent: Option, + context_window_used_tokens: Option, +} + +pub(crate) struct BottomPaneParams { + pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, + pub(crate) has_input_focus: bool, + pub(crate) enhanced_keys_supported: bool, + pub(crate) placeholder_text: String, + pub(crate) disable_paste_burst: bool, + pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, +} + +impl BottomPane { + pub fn new(params: BottomPaneParams) -> Self { + let BottomPaneParams { + app_event_tx, + frame_requester, + has_input_focus, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + animations_enabled, + skills, + } = params; + let mut composer = ChatComposer::new( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ); + composer.set_skill_mentions(skills); + + Self { + composer, + view_stack: Vec::new(), + app_event_tx, + frame_requester, + has_input_focus, + is_task_running: false, + ctrl_c_quit_hint: false, + status: None, + queued_user_messages: QueuedUserMessages::new(), + esc_backtrack_hint: false, + animations_enabled, + context_window_percent: None, + context_window_used_tokens: None, + } + } + + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + + #[cfg(test)] + pub(crate) fn context_window_percent(&self) -> Option { + self.context_window_percent + } + + #[cfg(test)] + pub(crate) fn context_window_used_tokens(&self) -> Option { + self.context_window_used_tokens + } + + fn active_view(&self) -> Option<&dyn BottomPaneView> { + self.view_stack.last().map(std::convert::AsRef::as_ref) + } + + fn push_view(&mut self, view: Box) { + self.view_stack.push(view); + self.request_redraw(); + } + + /// Forward a key event to the active view or the composer. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { + // If a modal/view is active, handle it here; otherwise forward to composer. + if let Some(view) = self.view_stack.last_mut() { + if key_event.code == KeyCode::Esc + && matches!(view.on_ctrl_c(), CancellationEvent::Handled) + && view.is_complete() + { + self.view_stack.pop(); + self.on_active_view_complete(); + } else { + view.handle_key_event(key_event); + if view.is_complete() { + self.view_stack.clear(); + self.on_active_view_complete(); + } + } + self.request_redraw(); + InputResult::None + } else { + // If a task is running and a status line is visible, allow Esc to + // send an interrupt even while the composer has focus. + if matches!(key_event.code, crossterm::event::KeyCode::Esc) + && self.is_task_running + && let Some(status) = &self.status + { + // Send Op::Interrupt + status.interrupt(); + self.request_redraw(); + return InputResult::None; + } + let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); + if needs_redraw { + self.request_redraw(); + } + if self.composer.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } + input_result + } + } + + /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a + /// chance to consume the event (e.g. to dismiss itself). + pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { + if let Some(view) = self.view_stack.last_mut() { + let event = view.on_ctrl_c(); + if matches!(event, CancellationEvent::Handled) { + if view.is_complete() { + self.view_stack.pop(); + self.on_active_view_complete(); + } + self.show_ctrl_c_quit_hint(); + } + event + } else if self.composer_is_empty() { + CancellationEvent::NotHandled + } else { + self.view_stack.pop(); + self.clear_composer_for_ctrl_c(); + self.show_ctrl_c_quit_hint(); + CancellationEvent::Handled + } + } + + pub fn handle_paste(&mut self, pasted: String) { + if let Some(view) = self.view_stack.last_mut() { + let needs_redraw = view.handle_paste(pasted); + if view.is_complete() { + self.on_active_view_complete(); + } + if needs_redraw { + self.request_redraw(); + } + } else { + let needs_redraw = self.composer.handle_paste(pasted); + if needs_redraw { + self.request_redraw(); + } + } + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.composer.insert_str(text); + self.request_redraw(); + } + + /// Replace the composer text with `text`. + pub(crate) fn set_composer_text(&mut self, text: String) { + self.composer.set_text_content(text); + self.request_redraw(); + } + + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { + self.composer.clear_for_ctrl_c(); + self.request_redraw(); + } + + /// Get the current composer text (for tests and programmatic checks). + pub(crate) fn composer_text(&self) -> String { + self.composer.current_text() + } + + /// Update the animated header shown to the left of the brackets in the + /// status indicator (defaults to "Working"). No-ops if the status + /// indicator is not active. + pub(crate) fn update_status_header(&mut self, header: String) { + if let Some(status) = self.status.as_mut() { + status.update_header(header); + self.request_redraw(); + } + } + + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { + self.ctrl_c_quit_hint = true; + self.composer + .set_ctrl_c_quit_hint(true, self.has_input_focus); + self.request_redraw(); + } + + pub(crate) fn clear_ctrl_c_quit_hint(&mut self) { + if self.ctrl_c_quit_hint { + self.ctrl_c_quit_hint = false; + self.composer + .set_ctrl_c_quit_hint(false, self.has_input_focus); + self.request_redraw(); + } + } + + #[cfg(test)] + pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool { + self.ctrl_c_quit_hint + } + + #[cfg(test)] + pub(crate) fn status_indicator_visible(&self) -> bool { + self.status.is_some() + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.esc_backtrack_hint = true; + self.composer.set_esc_backtrack_hint(true); + self.request_redraw(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + if self.esc_backtrack_hint { + self.esc_backtrack_hint = false; + self.composer.set_esc_backtrack_hint(false); + self.request_redraw(); + } + } + + // esc_backtrack_hint_visible removed; hints are controlled internally. + + pub fn set_task_running(&mut self, running: bool) { + let was_running = self.is_task_running; + self.is_task_running = running; + self.composer.set_task_running(running); + + if running { + if !was_running { + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + )); + } + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(true); + } + self.request_redraw(); + } + } else { + // Hide the status indicator when a task completes, but keep other modal views. + self.hide_status_indicator(); + } + } + + /// Hide the status indicator while leaving task-running state untouched. + pub(crate) fn hide_status_indicator(&mut self) { + if self.status.take().is_some() { + self.request_redraw(); + } + } + + pub(crate) fn ensure_status_indicator(&mut self) { + if self.status.is_none() { + self.status = Some(StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + )); + self.request_redraw(); + } + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + if let Some(status) = self.status.as_mut() { + status.set_interrupt_hint_visible(visible); + self.request_redraw(); + } + } + + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { + if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + { + return; + } + + self.context_window_percent = percent; + self.context_window_used_tokens = used_tokens; + self.composer + .set_context_window(percent, self.context_window_used_tokens); + self.request_redraw(); + } + + /// Show a generic list selection view with the provided items. + pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { + let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); + self.push_view(Box::new(view)); + } + + /// Update the queued messages preview shown above the composer. + pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { + self.queued_user_messages.messages = queued; + self.request_redraw(); + } + + /// Update custom prompts available for the slash popup. + pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { + self.composer.set_custom_prompts(prompts); + self.request_redraw(); + } + + pub(crate) fn composer_is_empty(&self) -> bool { + self.composer.is_empty() + } + + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + + /// Return true when the pane is in the regular composer state without any + /// overlays or popups and not running a task. This is the safe context to + /// use Esc-Esc for backtracking from the main view. + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() + } + + pub(crate) fn show_view(&mut self, view: Box) { + self.push_view(view); + } + + /// Called when the agent requests user approval. + pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_approval_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + // Otherwise create a new approval modal overlay. + let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); + self.pause_status_timer_for_modal(); + self.push_view(Box::new(modal)); + } + + fn on_active_view_complete(&mut self) { + self.resume_status_timer_after_modal(); + } + + fn pause_status_timer_for_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.pause_timer(); + } + } + + fn resume_status_timer_after_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.resume_timer(); + } + } + + /// Height (terminal rows) required by the current bottom pane. + pub(crate) fn request_redraw(&self) { + self.frame_requester.schedule_frame(); + } + + pub(crate) fn request_redraw_in(&self, dur: Duration) { + self.frame_requester.schedule_frame_in(dur); + } + + // --- History helpers --- + + pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { + self.composer.set_history_metadata(log_id, entry_count); + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + + pub(crate) fn on_history_entry_response( + &mut self, + log_id: u64, + offset: usize, + entry: Option, + ) { + let updated = self + .composer + .on_history_entry_response(log_id, offset, entry); + + if updated { + self.request_redraw(); + } + } + + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + self.composer.on_file_search_result(query, matches); + self.request_redraw(); + } + + pub(crate) fn attach_image( + &mut self, + path: PathBuf, + width: u32, + height: u32, + format_label: &str, + ) { + if self.view_stack.is_empty() { + self.composer + .attach_image(path, width, height, format_label); + self.request_redraw(); + } + } + + pub(crate) fn take_recent_submission_images(&mut self) -> Vec { + self.composer.take_recent_submission_images() + } + + fn as_renderable(&'_ self) -> RenderableItem<'_> { + if let Some(view) = self.active_view() { + RenderableItem::Borrowed(view) + } else { + let mut flex = FlexRenderable::new(); + if let Some(status) = &self.status { + flex.push(0, RenderableItem::Borrowed(status)); + } + flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + flex.push(0, RenderableItem::Owned("".into())); + } + let mut flex2 = FlexRenderable::new(); + flex2.push(1, RenderableItem::Owned(flex.into())); + flex2.push(0, RenderableItem::Borrowed(&self.composer)); + RenderableItem::Owned(Box::new(flex2)) + } + } +} + +impl Renderable for BottomPane { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(pane: &BottomPane, area: Rect) -> String { + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + snapshot_buffer(&buf) + } + + fn exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "1".to_string(), + command: vec!["echo".into(), "ok".into()], + reason: None, + proposed_execpolicy_amendment: None, + } + } + + #[test] + fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + pane.push_approval_request(exec_request(), &features); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert!(pane.ctrl_c_quit_hint_visible()); + assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); + } + + // live ring removed; related tests deleted. + + #[test] + fn overlay_not_shown_above_approval_modal() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Create an approval modal (active view). + pane.push_approval_request(exec_request(), &features); + + // Render and verify the top row does not include an overlay. + let area = Rect::new(0, 0, 60, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let mut r0 = String::new(); + for x in 0..area.width { + r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + !r0.contains("Working"), + "overlay should not render above modal" + ); + } + + #[test] + fn composer_shown_after_denied_while_task_running() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Start a running task so the status indicator is active above the composer. + pane.set_task_running(true); + + // Push an approval modal (e.g., command approval) which should hide the status view. + pane.push_approval_request(exec_request(), &features); + + // Simulate pressing 'n' (No) on the modal. + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + // After denial, since the task is still running, the status indicator should be + // visible above the composer. The modal should be gone. + assert!( + pane.view_stack.is_empty(), + "no active modal view after denial" + ); + + // Render and ensure the top row includes the Working header and a composer line below. + // Give the animation thread a moment to tick. + std::thread::sleep(Duration::from_millis(120)); + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + let mut row0 = String::new(); + for x in 0..area.width { + row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + row0.contains("Working"), + "expected Working header after denial on row 0: {row0:?}" + ); + + // Composer placeholder should be visible somewhere below. + let mut found_composer = false; + for y in 1..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("Ask Codex") { + found_composer = true; + break; + } + } + assert!( + found_composer, + "expected composer visible under status line" + ); + } + + #[test] + fn status_indicator_visible_during_command_execution() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Begin a task: show initial status. + pane.set_task_running(true); + + // Use a height that allows the status line to be visible above the composer. + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + pane.render(area, &mut buf); + + let bufs = snapshot_buffer(&buf); + assert!(bufs.contains("• Working"), "expected Working header"); + } + + #[test] + fn status_and_composer_fill_height_without_bottom_padding() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + // Activate spinner (status view replaces composer) with no live ring. + pane.set_task_running(true); + + // Use height == desired_height; expect spacer + status + composer rows without trailing padding. + let height = pane.desired_height(30); + assert!( + height >= 3, + "expected at least 3 rows to render spacer, status, and composer; got {height}" + ); + let area = Rect::new(0, 0, 30, height); + assert_snapshot!( + "status_and_composer_fill_height_without_bottom_padding", + render_snapshot(&pane, area) + ); + } + + #[test] + fn queued_messages_visible_when_status_hidden_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.hide_status_indicator(); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "queued_messages_visible_when_status_hidden_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn status_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs new file mode 100644 index 0000000000..49377cb21c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -0,0 +1,267 @@ +use std::time::Duration; +use std::time::Instant; + +// Heuristic thresholds for detecting paste-like input bursts. +// Detect quickly to avoid showing typed prefix before paste is recognized +const PASTE_BURST_MIN_CHARS: u16 = 3; +const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); +const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); + +#[derive(Default)] +pub(crate) struct PasteBurst { + last_plain_char_time: Option, + consecutive_plain_char_burst: u16, + burst_window_until: Option, + buffer: String, + active: bool, + // Hold first fast char briefly to avoid rendering flicker + pending_first_char: Option<(char, Instant)>, +} + +pub(crate) enum CharDecision { + /// Start buffering and retroactively capture some already-inserted chars. + BeginBuffer { retro_chars: u16 }, + /// We are currently buffering; append the current char into the buffer. + BufferAppend, + /// Do not insert/render this char yet; temporarily save the first fast + /// char while we wait to see if a paste-like burst follows. + RetainFirstChar, + /// Begin buffering using the previously saved first char (no retro grab needed). + BeginBufferFromPending, +} + +pub(crate) struct RetroGrab { + pub start_byte: usize, + pub grabbed: String, +} + +pub(crate) enum FlushResult { + Paste(String), + Typed(char), + None, +} + +impl PasteBurst { + /// Recommended delay to wait between simulated keypresses (or before + /// scheduling a UI tick) so that a pending fast keystroke is flushed + /// out of the burst detector as normal typed input. + /// + /// Primarily used by tests and by the TUI to reliably cross the + /// paste-burst timing threshold. + pub fn recommended_flush_delay() -> Duration { + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) + } + + /// Entry point: decide how to treat a plain char with current timing. + pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { + match self.last_plain_char_time { + Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { + self.consecutive_plain_char_burst = + self.consecutive_plain_char_burst.saturating_add(1) + } + _ => self.consecutive_plain_char_burst = 1, + } + self.last_plain_char_time = Some(now); + + if self.active { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BufferAppend; + } + + // If we already held a first char and receive a second fast char, + // start buffering without retro-grabbing (we never rendered the first). + if let Some((held, held_at)) = self.pending_first_char + && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL + { + self.active = true; + // take() to clear pending; we already captured the held char above + let _ = self.pending_first_char.take(); + self.buffer.push(held); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BeginBufferFromPending; + } + + if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + return CharDecision::BeginBuffer { + retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), + }; + } + + // Save the first fast char very briefly to see if a burst follows. + self.pending_first_char = Some((ch, now)); + CharDecision::RetainFirstChar + } + + /// Flush the buffered burst if the inter-key timeout has elapsed. + /// + /// Returns Some(String) when either: + /// - We were actively buffering paste-like input and the buffer is now + /// emitted as a single pasted string; or + /// - We had saved a single fast first-char with no subsequent burst and we + /// now emit that char as normal typed input. + /// + /// Returns None if the timeout has not elapsed or there is nothing to flush. + pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); + if timed_out && self.is_active_internal() { + self.active = false; + let out = std::mem::take(&mut self.buffer); + FlushResult::Paste(out) + } else if timed_out { + // If we were saving a single fast char and no burst followed, + // flush it as normal typed input. + if let Some((ch, _at)) = self.pending_first_char.take() { + FlushResult::Typed(ch) + } else { + FlushResult::None + } + } else { + FlushResult::None + } + } + + /// While bursting: accumulate a newline into the buffer instead of + /// submitting the textarea. + /// + /// Returns true if a newline was appended (we are in a burst context), + /// false otherwise. + pub fn append_newline_if_active(&mut self, now: Instant) -> bool { + if self.is_active() { + self.buffer.push('\n'); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + true + } else { + false + } + } + + /// Decide if Enter should insert a newline (burst context) vs submit. + pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { + let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); + self.is_active() || in_burst_window + } + + /// Keep the burst window alive. + pub fn extend_window(&mut self, now: Instant) { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Begin buffering with retroactively grabbed text. + pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { + if !grabbed.is_empty() { + self.buffer.push_str(&grabbed); + } + self.active = true; + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Append a char into the burst buffer. + pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { + self.buffer.push(ch); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Try to append a char into the burst buffer only if a burst is already active. + /// + /// Returns true when the char was captured into the existing burst, false otherwise. + pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { + if self.active || !self.buffer.is_empty() { + self.append_char_to_buffer(ch, now); + true + } else { + false + } + } + + /// Decide whether to begin buffering by retroactively capturing recent + /// chars from the slice before the cursor. + /// + /// Heuristic: if the retro-grabbed slice contains any whitespace or is + /// sufficiently long (>= 16 characters), treat it as paste-like to avoid + /// rendering the typed prefix momentarily before the paste is recognized. + /// This favors responsiveness and prevents flicker for typical pastes + /// (URLs, file paths, multiline text) while not triggering on short words. + /// + /// Returns Some(RetroGrab) with the start byte and grabbed text when we + /// decide to buffer retroactively; otherwise None. + pub fn decide_begin_buffer( + &mut self, + now: Instant, + before: &str, + retro_chars: usize, + ) -> Option { + let start_byte = retro_start_index(before, retro_chars); + let grabbed = before[start_byte..].to_string(); + let looks_pastey = + grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16; + if looks_pastey { + // Note: caller is responsible for removing this slice from UI text. + self.begin_with_retro_grabbed(grabbed.clone(), now); + Some(RetroGrab { + start_byte, + grabbed, + }) + } else { + None + } + } + + /// Before applying modified/non-char input: flush buffered burst immediately. + pub fn flush_before_modified_input(&mut self) -> Option { + if !self.is_active() { + return None; + } + self.active = false; + let mut out = std::mem::take(&mut self.buffer); + if let Some((ch, _at)) = self.pending_first_char.take() { + out.push(ch); + } + Some(out) + } + + /// Clear only the timing window and any pending first-char. + /// + /// Does not emit or clear the buffered text itself; callers should have + /// already flushed (if needed) via one of the flush methods above. + pub fn clear_window_after_non_char(&mut self) { + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.burst_window_until = None; + self.active = false; + self.pending_first_char = None; + } + + /// Returns true if we are in any paste-burst related transient state + /// (actively buffering, have a non-empty buffer, or have saved the first + /// fast char while waiting for a potential burst). + pub fn is_active(&self) -> bool { + self.is_active_internal() || self.pending_first_char.is_some() + } + + fn is_active_internal(&self) -> bool { + self.active || !self.buffer.is_empty() + } + + pub fn clear_after_explicit_paste(&mut self) { + self.last_plain_char_time = None; + self.consecutive_plain_char_burst = 0; + self.burst_window_until = None; + self.active = false; + self.buffer.clear(); + self.pending_first_char = None; + } +} + +pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { + if retro_chars == 0 { + return before.len(); + } + before + .char_indices() + .rev() + .nth(retro_chars.saturating_sub(1)) + .map(|(idx, _)| idx) + .unwrap_or(0) +} diff --git a/codex-rs/tui2/src/bottom_pane/popup_consts.rs b/codex-rs/tui2/src/bottom_pane/popup_consts.rs new file mode 100644 index 0000000000..2cabe389b1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/popup_consts.rs @@ -0,0 +1,21 @@ +//! Shared popup-related constants for bottom pane widgets. + +use crossterm::event::KeyCode; +use ratatui::text::Line; + +use crate::key_hint; + +/// Maximum number of rows any popup should attempt to display. +/// Keep this consistent across all popups for a uniform feel. +pub(crate) const MAX_POPUP_ROWS: usize = 8; + +/// Standard footer hint text used by popups. +pub(crate) fn standard_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) +} diff --git a/codex-rs/tui2/src/bottom_pane/prompt_args.rs b/codex-rs/tui2/src/bottom_pane/prompt_args.rs new file mode 100644 index 0000000000..48c3cedfab --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/prompt_args.rs @@ -0,0 +1,406 @@ +use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use lazy_static::lazy_static; +use regex_lite::Regex; +use shlex::Shlex; +use std::collections::HashMap; +use std::collections::HashSet; + +lazy_static! { + static ref PROMPT_ARG_REGEX: Regex = + Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); +} + +#[derive(Debug)] +pub enum PromptArgsError { + MissingAssignment { token: String }, + MissingKey { token: String }, +} + +impl PromptArgsError { + fn describe(&self, command: &str) -> String { + match self { + PromptArgsError::MissingAssignment { token } => format!( + "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." + ), + PromptArgsError::MissingKey { token } => { + format!("Could not parse {command}: expected a name before '=' in '{token}'.") + } + } + } +} + +#[derive(Debug)] +pub enum PromptExpansionError { + Args { + command: String, + error: PromptArgsError, + }, + MissingArgs { + command: String, + missing: Vec, + }, +} + +impl PromptExpansionError { + pub fn user_message(&self) -> String { + match self { + PromptExpansionError::Args { command, error } => error.describe(command), + PromptExpansionError::MissingArgs { command, missing } => { + let list = missing.join(", "); + format!( + "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." + ) + } + } + } +} + +/// Parse a first-line slash command of the form `/name `. +/// Returns `(name, rest_after_name)` if the line begins with `/` and contains +/// a non-empty name; otherwise returns `None`. +pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { + let stripped = line.strip_prefix('/')?; + let mut name_end = stripped.len(); + for (idx, ch) in stripped.char_indices() { + if ch.is_whitespace() { + name_end = idx; + break; + } + } + let name = &stripped[..name_end]; + if name.is_empty() { + return None; + } + let rest = stripped[name_end..].trim_start(); + Some((name, rest)) +} + +/// Parse positional arguments using shlex semantics (supports quoted tokens). +pub fn parse_positional_args(rest: &str) -> Vec { + Shlex::new(rest).collect() +} + +/// Extracts the unique placeholder variable names from a prompt template. +/// +/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` +/// (for example `$USER`). The function returns the variable names without +/// the leading `$`, de-duplicated and in the order of first appearance. +pub fn prompt_argument_names(content: &str) -> Vec { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for m in PROMPT_ARG_REGEX.find_iter(content) { + if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { + continue; + } + let name = &content[m.start() + 1..m.end()]; + // Exclude special positional aggregate token from named args. + if name == "ARGUMENTS" { + continue; + } + let name = name.to_string(); + if seen.insert(name.clone()) { + names.push(name); + } + } + names +} + +/// Parses the `key=value` pairs that follow a custom prompt name. +/// +/// The input is split using shlex rules, so quoted values are supported +/// (for example `USER="Alice Smith"`). The function returns a map of parsed +/// arguments, or an error if a token is missing `=` or if the key is empty. +pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { + let mut map = HashMap::new(); + if rest.trim().is_empty() { + return Ok(map); + } + + for token in Shlex::new(rest) { + let Some((key, value)) = token.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token }); + }; + if key.is_empty() { + return Err(PromptArgsError::MissingKey { token }); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. +/// +/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, +/// the function returns `Ok(None)`. On success it returns +/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. +pub fn expand_custom_prompt( + text: &str, + custom_prompts: &[CustomPrompt], +) -> Result, PromptExpansionError> { + let Some((name, rest)) = parse_slash_name(text) else { + return Ok(None); + }; + + // Only handle custom prompts when using the explicit prompts prefix with a colon. + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + + let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { + Some(prompt) => prompt, + None => return Ok(None), + }; + // If there are named placeholders, expect key=value inputs. + let required = prompt_argument_names(&prompt.content); + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { + command: format!("/{name}"), + error, + })?; + let missing: Vec = required + .into_iter() + .filter(|k| !inputs.contains_key(k)) + .collect(); + if !missing.is_empty() { + return Err(PromptExpansionError::MissingArgs { + command: format!("/{name}"), + missing, + }); + } + let content = &prompt.content; + let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { + if let Some(matched) = caps.get(0) + && matched.start() > 0 + && content.as_bytes()[matched.start() - 1] == b'$' + { + return matched.as_str().to_string(); + } + let whole = &caps[0]; + let key = &whole[1..]; + inputs + .get(key) + .cloned() + .unwrap_or_else(|| whole.to_string()) + }); + return Ok(Some(replaced.into_owned())); + } + + // Otherwise, treat it as numeric/positional placeholder prompt (or none). + let pos_args: Vec = Shlex::new(rest).collect(); + let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); + Ok(Some(expanded)) +} + +/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. +pub fn prompt_has_numeric_placeholders(content: &str) -> bool { + if content.contains("$ARGUMENTS") { + return true; + } + let bytes = content.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'$' { + let b1 = bytes[i + 1]; + if (b'1'..=b'9').contains(&b1) { + return true; + } + } + i += 1; + } + false +} + +/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. +/// Returns empty when the command name does not match or when there are no args. +pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix('/') else { + return Vec::new(); + }; + // Require the explicit prompts prefix for custom prompt invocations. + let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + let mut parts = after_prefix.splitn(2, char::is_whitespace); + let cmd = parts.next().unwrap_or(""); + if cmd != prompt_name { + return Vec::new(); + } + let args_str = parts.next().unwrap_or("").trim(); + if args_str.is_empty() { + return Vec::new(); + } + parse_positional_args(args_str) +} + +/// If the prompt only uses numeric placeholders and the first line contains +/// positional args for it, expand and return Some(expanded); otherwise None. +pub fn expand_if_numeric_with_positional_args( + prompt: &CustomPrompt, + first_line: &str, +) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } + if !prompt_has_numeric_placeholders(&prompt.content) { + return None; + } + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + if args.is_empty() { + return None; + } + Some(expand_numeric_placeholders(&prompt.content, &args)) +} + +/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. +pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { + let mut out = String::with_capacity(content.len()); + let mut i = 0; + let mut cached_joined_args: Option = None; + while let Some(off) = content[i..].find('$') { + let j = i + off; + out.push_str(&content[i..j]); + let rest = &content[j..]; + let bytes = rest.as_bytes(); + if bytes.len() >= 2 { + match bytes[1] { + b'$' => { + out.push_str("$$"); + i = j + 2; + continue; + } + b'1'..=b'9' => { + let idx = (bytes[1] - b'1') as usize; + if let Some(val) = args.get(idx) { + out.push_str(val); + } + i = j + 2; + continue; + } + _ => {} + } + } + if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if !args.is_empty() { + let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); + out.push_str(joined); + } + i = j + 1 + "ARGUMENTS".len(); + continue; + } + out.push('$'); + i = j + 1; + } + out.push_str(&content[i..]); + out +} + +/// Constructs a command text for a custom prompt with arguments. +/// Returns the text and the cursor position (inside the first double quote). +pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor: usize = text.len(); + for (i, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if i == 0 { + cursor = text.len() - 1; // inside first "" + } + } + (text, cursor) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_arguments_basic() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = + expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); + assert_eq!(out, Some("Review Alice changes on main".to_string())); + } + + #[test] + fn quoted_values_ok() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt( + "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &prompts, + ) + .unwrap(); + assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + } + + #[test] + fn invalid_arg_token_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + .unwrap_err() + .user_message(); + assert!(err.contains("expected key=value")); + } + + #[test] + fn missing_required_args_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + .unwrap_err() + .user_message(); + assert!(err.to_lowercase().contains("missing required args")); + assert!(err.contains("BRANCH")); + } + + #[test] + fn escaped_placeholder_is_ignored() { + assert_eq!( + prompt_argument_names("literal $$USER"), + Vec::::new() + ); + assert_eq!( + prompt_argument_names("literal $$USER and $REAL"), + vec!["REAL".to_string()] + ); + } + + #[test] + fn escaped_placeholder_remains_literal() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "literal $$USER".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); + assert_eq!(out, Some("literal $$USER".to_string())); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs new file mode 100644 index 0000000000..ae33aeada4 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs @@ -0,0 +1,157 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +/// Widget that displays a list of user messages queued while a turn is in progress. +pub(crate) struct QueuedUserMessages { + pub messages: Vec, +} + +impl QueuedUserMessages { + pub(crate) fn new() -> Self { + Self { + messages: Vec::new(), + } + } + + fn as_renderable(&self, width: u16) -> Box { + if self.messages.is_empty() || width < 4 { + return Box::new(()); + } + + let mut lines = vec![]; + + for message in &self.messages { + let wrapped = word_wrap_lines( + message.lines().map(|line| line.dim().italic()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + let len = wrapped.len(); + for line in wrapped.into_iter().take(3) { + lines.push(line); + } + if len > 3 { + lines.push(Line::from(" …".dim().italic())); + } + } + + lines.push( + Line::from(vec![ + " ".into(), + key_hint::alt(KeyCode::Up).into(), + " edit".into(), + ]) + .dim(), + ); + + Paragraph::new(lines).into() + } +} + +impl Renderable for QueuedUserMessages { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let queue = QueuedUserMessages::new(); + assert_eq!(queue.desired_height(40), 0); + } + + #[test] + fn desired_height_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + assert_eq!(queue.desired_height(40), 2); + } + + #[test] + fn render_one_message() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_message", format!("{buf:?}")); + } + + #[test] + fn render_two_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_two_messages", format!("{buf:?}")); + } + + #[test] + fn render_more_than_three_messages() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push("Hello, world!".to_string()); + queue.messages.push("This is another message".to_string()); + queue.messages.push("This is a third message".to_string()); + queue.messages.push("This is a fourth message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); + } + + #[test] + fn render_wrapped_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is a longer message that should be wrapped".to_string()); + queue.messages.push("This is another message".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_wrapped_message", format!("{buf:?}")); + } + + #[test] + fn render_many_line_message() { + let mut queue = QueuedUserMessages::new(); + queue + .messages + .push("This is\na message\nwith many\nlines".to_string()); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_many_line_message", format!("{buf:?}")); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/scroll_state.rs b/codex-rs/tui2/src/bottom_pane/scroll_state.rs new file mode 100644 index 0000000000..a9728d1a0d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/scroll_state.rs @@ -0,0 +1,115 @@ +/// Generic scroll/selection state for a vertical list menu. +/// +/// Encapsulates the common behavior of a selectable list that supports: +/// - Optional selection (None when list is empty) +/// - Wrap-around navigation on Up/Down +/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct ScrollState { + pub selected_idx: Option, + pub scroll_top: usize, +} + +impl ScrollState { + pub fn new() -> Self { + Self { + selected_idx: None, + scroll_top: 0, + } + } + + /// Reset selection and scroll. + pub fn reset(&mut self) { + self.selected_idx = None; + self.scroll_top = 0; + } + + /// Clamp selection to be within the [0, len-1] range, or None when empty. + pub fn clamp_selection(&mut self, len: usize) { + self.selected_idx = match len { + 0 => None, + _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), + }; + if len == 0 { + self.scroll_top = 0; + } + } + + /// Move selection up by one, wrapping to the bottom when necessary. + pub fn move_up_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx > 0 => idx - 1, + Some(_) => len - 1, + None => 0, + }); + } + + /// Move selection down by one, wrapping to the top when necessary. + pub fn move_down_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx + 1 < len => idx + 1, + _ => 0, + }); + } + + /// Adjust `scroll_top` so that the current `selected_idx` is visible within + /// the window of `visible_rows`. + pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { + if len == 0 || visible_rows == 0 { + self.scroll_top = 0; + return; + } + if let Some(sel) = self.selected_idx { + if sel < self.scroll_top { + self.scroll_top = sel; + } else { + let bottom = self.scroll_top + visible_rows - 1; + if sel > bottom { + self.scroll_top = sel + 1 - visible_rows; + } + } + } else { + self.scroll_top = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::ScrollState; + + #[test] + fn wrap_navigation_and_visibility() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 5; + + s.clamp_selection(len); + assert_eq!(s.selected_idx, Some(0)); + s.ensure_visible(len, vis); + assert_eq!(s.scroll_top, 0); + + s.move_up_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(len - 1)); + match s.selected_idx { + Some(sel) => assert!(s.scroll_top <= sel), + None => panic!("expected Some(selected_idx) after wrap"), + } + + s.move_down_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs new file mode 100644 index 0000000000..5107ab0ca9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs @@ -0,0 +1,269 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +// Note: Table-based layout previously used Constraint; the manual renderer +// below no longer requires it. +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar; + +use crate::key_hint::KeyBinding; + +use super::scroll_state::ScrollState; + +/// A generic representation of a display row for selection popups. +pub(crate) struct GenericDisplayRow { + pub name: String, + pub display_shortcut: Option, + pub match_indices: Option>, // indices to bold (char positions) + pub description: Option, // optional grey text after the name + pub wrap_indent: Option, // optional indent for wrapped lines +} + +/// Compute a shared description-column start based on the widest visible name +/// plus two spaces of padding. Ensures at least one column is left for the +/// description. +fn compute_desc_col( + rows_all: &[GenericDisplayRow], + start_idx: usize, + visible_items: usize, + content_width: u16, +) -> usize { + let visible_range = start_idx..(start_idx + visible_items); + let max_name_width = rows_all + .iter() + .enumerate() + .filter(|(i, _)| visible_range.contains(i)) + .map(|(_, r)| Line::from(r.name.clone()).width()) + .max() + .unwrap_or(0); + let mut desc_col = max_name_width.saturating_add(2); + if (desc_col as u16) >= content_width { + desc_col = content_width.saturating_sub(1) as usize; + } + desc_col +} + +/// Determine how many spaces to indent wrapped lines for a row. +fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { + let max_indent = max_width.saturating_sub(1) as usize; + let indent = row.wrap_indent.unwrap_or_else(|| { + if row.description.is_some() { + desc_col + } else { + 0 + } + }); + indent.min(max_indent) +} + +/// Build the full display line for a row with the description padded to start +/// at `desc_col`. Applies fuzzy-match bolding when indices are present and +/// dims the description. +fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { + // Enforce single-line name: allow at most desc_col - 2 cells for name, + // reserving two spaces before the description column. + let name_limit = row + .description + .as_ref() + .map(|_| desc_col.saturating_sub(2)) + .unwrap_or(usize::MAX); + + let mut name_spans: Vec = Vec::with_capacity(row.name.len()); + let mut used_width = 0usize; + let mut truncated = false; + + if let Some(idxs) = row.match_indices.as_ref() { + let mut idx_iter = idxs.iter().peekable(); + for (char_idx, ch) in row.name.chars().enumerate() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + name_spans.push(ch.to_string().bold()); + } else { + name_spans.push(ch.to_string().into()); + } + } + } else { + for ch in row.name.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + name_spans.push(ch.to_string().into()); + } + } + + if truncated { + // If there is at least one cell available, add an ellipsis. + // When name_limit is 0, we still show an ellipsis to indicate truncation. + name_spans.push("…".into()); + } + + let this_name_width = Line::from(name_spans.clone()).width(); + let mut full_spans: Vec = name_spans; + if let Some(display_shortcut) = row.display_shortcut { + full_spans.push(" (".into()); + full_spans.push(display_shortcut.into()); + full_spans.push(")".into()); + } + if let Some(desc) = row.description.as_ref() { + let gap = desc_col.saturating_sub(this_name_width); + if gap > 0 { + full_spans.push(" ".repeat(gap).into()); + } + full_spans.push(desc.clone().dim()); + } + Line::from(full_spans) +} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +pub(crate) fn render_rows( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) { + if rows_all.is_empty() { + if area.height > 0 { + Line::from(empty_message.dim().italic()).render(area, buf); + } + return; + } + + // Determine which logical rows (items) are visible given the selection and + // the max_results clamp. Scrolling is still item-based for simplicity. + let visible_items = max_results + .min(rows_all.len()) + .min(area.height.max(1) as usize); + + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); + + // Render items, wrapping descriptions and aligning wrapped lines under the + // shared description column. Stop when we run out of vertical space. + let mut cur_y = area.y; + for (i, row) in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + { + if cur_y >= area.y + area.height { + break; + } + + let mut full_line = build_full_line(row, desc_col); + if Some(i) == state.selected_idx { + // Match previous behavior: cyan + bold for the selected row. + // Reset the style first to avoid inheriting dim from keyboard shortcuts. + full_line.spans.iter_mut().for_each(|span| { + span.style = Style::default().fg(Color::Cyan).bold(); + }); + } + + // Wrap with subsequent indent aligned to the description column. + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let continuation_indent = wrap_indent(row, desc_col, area.width); + let options = RtOptions::new(area.width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); + let wrapped = word_wrap_line(&full_line, options); + + // Render the wrapped lines. + for line in wrapped { + if cur_y >= area.y + area.height { + break; + } + line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + } + } +} + +/// Compute the number of terminal rows required to render up to `max_results` +/// items from `rows_all` given the current scroll/selection state and the +/// available `width`. Accounts for description wrapping and alignment so the +/// caller can allocate sufficient vertical space. +pub(crate) fn measure_rows_height( + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, + width: u16, +) -> u16 { + if rows_all.is_empty() { + return 1; // placeholder "no matches" line + } + + let content_width = width.saturating_sub(1).max(1); + + let visible_items = max_results.min(rows_all.len()); + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); + + use crate::wrapping::RtOptions; + use crate::wrapping::word_wrap_line; + let mut total: u16 = 0; + for row in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_items) + .map(|(_, r)| r) + { + let full_line = build_full_line(row, desc_col); + let continuation_indent = wrap_indent(row, desc_col, content_width); + let opts = RtOptions::new(content_width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); + total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); + } + total.max(1) +} diff --git a/codex-rs/tui2/src/bottom_pane/skill_popup.rs b/codex-rs/tui2/src/bottom_pane/skill_popup.rs new file mode 100644 index 0000000000..3e0f79f84b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +pub(crate) struct SkillPopup { + query: String, + skills: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(skills: Vec) -> Self { + Self { + query: String::new(), + skills, + state: ScrollState::new(), + } + } + + pub(crate) fn set_skills(&mut self, skills: Vec) { + self.skills = skills; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let skill_idx = matches.get(idx)?; + self.skills.get(*skill_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let skill = &self.skills[idx]; + let slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + GenericDisplayRow { + name, + match_indices: indices, + display_shortcut: None, + description: Some(description), + wrap_indent: None, + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + if filter.is_empty() { + for (idx, _skill) in self.skills.iter().enumerate() { + out.push((idx, None, 0)); + } + return out; + } + + for (idx, skill) in self.skills.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no skills", + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap new file mode 100644 index 0000000000..00821b7910 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap new file mode 100644 index 0000000000..1a34b29f92 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 0000000000..d323fda148 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 0000000000..d9395f2b05 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 0000000000..9e93b8d683 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 0000000000..1d16779b01 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 0000000000..9e93b8d683 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 0000000000..0aa72ca002 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 0000000000..1d16779b01 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 0000000000..178182bfd7 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap new file mode 100644 index 0000000000..3b7711d75f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap new file mode 100644 index 0000000000..426afbec6e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 0000000000..dc66d149e4 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " +" /model choose what model and reasoning effort to use " +" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000..daedb3d888 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap new file mode 100644 index 0000000000..8f669e1cb9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› short " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap new file mode 100644 index 0000000000..f3c3a319bc --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap new file mode 100644 index 0000000000..2ab262c229 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap new file mode 100644 index 0000000000..6bd6846202 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap new file mode 100644 index 0000000000..1ec33c54ee --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +source: tui2/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 0000000000..e31cf10f06 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 0000000000..157853e73d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 0000000000..98bc87b38e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 0000000000..201bec4f62 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 0000000000..0bc46a989a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 0000000000..2dd8738fe0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 0000000000..286acadd8b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 0000000000..47508f3240 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap new file mode 100644 index 0000000000..b46a229ad4 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 80)" +--- + + Select Model and Effort + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning + quality and coding ability. + 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less + capable. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility + with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap new file mode 100644 index 0000000000..bcdc8a3561 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 24)" +--- + + Debug + +› 1. Item 1 + xxxxxxxxx + x + 2. Item 2 + xxxxxxxxx + x + 3. Item 3 + xxxxxxxxx + x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap new file mode 100644 index 0000000000..2cc2578c56 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + Switch between Codex approval presets + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap new file mode 100644 index 0000000000..88a5d14932 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap new file mode 100644 index 0000000000..c715e81c9a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap new file mode 100644 index 0000000000..1e88bfb5b1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap new file mode 100644 index 0000000000..8160a886db --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap new file mode 100644 index 0000000000..9b1ef9e5c6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap new file mode 100644 index 0000000000..f46cf990fa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui2/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 0000000000..71504561db --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap new file mode 100644 index 0000000000..f6c157922a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interru + + +› Ask Codex to do anything + + 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..6ac4296833 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap new file mode 100644 index 0000000000..e4cc9ffefd --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap new file mode 100644 index 0000000000..53e0aee4cf --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 0000000000..49ffb0d4c8 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 0000000000..7ecc5bba71 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 0000000000..9cad17b864 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 0000000000..2fce42cc26 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 0000000000..9cad17b864 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 0000000000..67e616e917 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 0000000000..2fce42cc26 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 0000000000..3b6782d06d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap new file mode 100644 index 0000000000..6b018021ec --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap new file mode 100644 index 0000000000..40098faee0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 0000000000..661e82e3ad --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " +" /model choose what model and reasoning effort to use " +" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000..df8ea36e63 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2385 +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap new file mode 100644 index 0000000000..498ed76936 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› short " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap new file mode 100644 index 0000000000..465f0f9c4f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap new file mode 100644 index 0000000000..a0b5660135 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap new file mode 100644 index 0000000000..73074d61fa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap new file mode 100644 index 0000000000..80e4ffeffe --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 0000000000..bafa94b09d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 0000000000..a77ca5565b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 0000000000..31a1b743b8 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 0000000000..9979372a1b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 0000000000..b2333b025f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 0000000000..20f9b178b4 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 0000000000..d05ac90a91 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 0000000000..c95a5dc0b3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 0000000000..264515a6c2 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap new file mode 100644 index 0000000000..be81978c89 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 80)" +--- + + Select Model and Effort + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning + quality and coding ability. + 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less + capable. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility + with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap new file mode 100644 index 0000000000..3ce6a3c45f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 24)" +--- + + Debug + +› 1. Item 1 + xxxxxxxxx + x + 2. Item 2 + xxxxxxxxx + x + 3. Item 3 + xxxxxxxxx + x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap new file mode 100644 index 0000000000..512f6bbca6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + Switch between Codex approval presets + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap new file mode 100644 index 0000000000..ddd0f90cd8 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap new file mode 100644 index 0000000000..cf1f7248b3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap new file mode 100644 index 0000000000..5e403e1bdd --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap new file mode 100644 index 0000000000..4484509695 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap new file mode 100644 index 0000000000..16d6361257 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap new file mode 100644 index 0000000000..d2afbf7dbd --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap new file mode 100644 index 0000000000..9d7527d16f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap new file mode 100644 index 0000000000..d47fa97863 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap new file mode 100644 index 0000000000..1f020fec64 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap new file mode 100644 index 0000000000..4f2917a6c4 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 0000000000..123a5eb3a3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap new file mode 100644 index 0000000000..86e3da4573 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interru + + +› Ask Codex to do anything + + 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000..27df671e4d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + ↳ Queued follow-up question + ⌥ + ↑ edit + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap new file mode 100644 index 0000000000..52f96e8557 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area1)" +--- +› Ask Codex to do a diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs new file mode 100644 index 0000000000..2fd415c7f6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -0,0 +1,2015 @@ +use crate::key_hint::is_altgr; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; +use std::cell::Ref; +use std::cell::RefCell; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +fn is_word_separator(ch: char) -> bool { + WORD_SEPARATORS.contains(ch) +} + +#[derive(Debug, Clone)] +struct TextElement { + range: Range, +} + +#[derive(Debug)] +pub(crate) struct TextArea { + text: String, + cursor_pos: usize, + wrap_cache: RefCell>, + preferred_col: Option, + elements: Vec, + kill_buffer: String, +} + +#[derive(Debug, Clone)] +struct WrapCache { + width: u16, + lines: Vec>, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct TextAreaState { + /// Index into wrapped lines of the first visible line. + scroll: u16, +} + +impl TextArea { + pub fn new() -> Self { + Self { + text: String::new(), + cursor_pos: 0, + wrap_cache: RefCell::new(None), + preferred_col: None, + elements: Vec::new(), + kill_buffer: String::new(), + } + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + self.wrap_cache.replace(None); + self.preferred_col = None; + self.elements.clear(); + self.kill_buffer.clear(); + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn insert_str(&mut self, text: &str) { + self.insert_str_at(self.cursor_pos, text); + } + + pub fn insert_str_at(&mut self, pos: usize, text: &str) { + let pos = self.clamp_pos_for_insertion(pos); + self.text.insert_str(pos, text); + self.wrap_cache.replace(None); + if pos <= self.cursor_pos { + self.cursor_pos += text.len(); + } + self.shift_elements(pos, 0, text.len()); + self.preferred_col = None; + } + + pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { + let range = self.expand_range_to_element_boundaries(range); + self.replace_range_raw(range, text); + } + + fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { + assert!(range.start <= range.end); + let start = range.start.clamp(0, self.text.len()); + let end = range.end.clamp(0, self.text.len()); + let removed_len = end - start; + let inserted_len = text.len(); + if removed_len == 0 && inserted_len == 0 { + return; + } + let diff = inserted_len as isize - removed_len as isize; + + self.text.replace_range(range, text); + self.wrap_cache.replace(None); + self.preferred_col = None; + self.update_elements_after_replace(start, end, inserted_len); + + // Update the cursor position to account for the edit. + self.cursor_pos = if self.cursor_pos < start { + // Cursor was before the edited range – no shift. + self.cursor_pos + } else if self.cursor_pos <= end { + // Cursor was inside the replaced range – move to end of the new text. + start + inserted_len + } else { + // Cursor was after the replaced range – shift by the length diff. + ((self.cursor_pos as isize) + diff) as usize + } + .min(self.text.len()); + + // Ensure cursor is not inside an element + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + } + + pub fn cursor(&self) -> usize { + self.cursor_pos + } + + pub fn set_cursor(&mut self, pos: usize) { + self.cursor_pos = pos.clamp(0, self.text.len()); + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + self.preferred_col = None; + } + + pub fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len() as u16 + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_with_state(area, TextAreaState::default()) + } + + /// Compute the on-screen cursor position taking scrolling into account. + pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { + let lines = self.wrapped_lines(area.width); + let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); + let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; + let ls = &lines[i]; + let col = self.text[ls.start..self.cursor_pos].width() as u16; + let screen_row = i + .saturating_sub(effective_scroll as usize) + .try_into() + .unwrap_or(0); + Some((area.x + col, area.y + screen_row)) + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + fn current_display_col(&self) -> usize { + let bol = self.beginning_of_current_line(); + self.text[bol..self.cursor_pos].width() + } + + fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { + // partition_point returns the index of the first element for which + // the predicate is false, i.e. the count of elements with start <= pos. + let idx = lines.partition_point(|r| r.start <= pos); + if idx == 0 { None } else { Some(idx - 1) } + } + + fn move_to_display_col_on_line( + &mut self, + line_start: usize, + line_end: usize, + target_col: usize, + ) { + let mut width_so_far = 0usize; + for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { + width_so_far += g.width(); + if width_so_far > target_col { + self.cursor_pos = line_start + i; + // Avoid landing inside an element; round to nearest boundary + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + return; + } + } + self.cursor_pos = line_end; + self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + } + + fn beginning_of_line(&self, pos: usize) -> usize { + self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) + } + fn beginning_of_current_line(&self) -> usize { + self.beginning_of_line(self.cursor_pos) + } + + fn end_of_line(&self, pos: usize) -> usize { + self.text[pos..] + .find('\n') + .map(|i| i + pos) + .unwrap_or(self.text.len()) + } + fn end_of_current_line(&self) -> usize { + self.end_of_line(self.cursor_pos) + } + + pub fn input(&mut self, event: KeyEvent) { + match event { + // Some terminals (or configurations) send Control key chords as + // C0 control characters without reporting the CONTROL modifier. + // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get + // inserted as literal control bytes. + KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { + self.move_cursor_left(); + } + KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { + self.move_cursor_right(); + } + KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { + self.move_cursor_up(); + } + KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { + self.move_cursor_down(); + } + KeyEvent { + code: KeyCode::Char(c), + // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, + // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) + // for word navigation. Those are handled explicitly below. + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, + .. + } => self.insert_str(&c.to_string()), + KeyEvent { + code: KeyCode::Char('j' | 'm'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Enter, + .. + } => self.insert_str("\n"), + KeyEvent { + code: KeyCode::Char('h'), + modifiers, + .. + } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { + self.delete_backward_word() + }, + // Windows AltGr generates ALT|CONTROL; treat as a plain character input unless + // we match a specific Control+Alt binding above. + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if is_altgr(modifiers) => self.insert_str(&c.to_string()), + KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::ALT, + .. + } => self.delete_backward_word(), + KeyEvent { + code: KeyCode::Backspace, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.delete_backward(1), + KeyEvent { + code: KeyCode::Delete, + modifiers: KeyModifiers::ALT, + .. + } => self.delete_forward_word(), + KeyEvent { + code: KeyCode::Delete, + .. + } + | KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.delete_forward(1), + + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.delete_backward_word(); + } + // Meta-b -> move to beginning of previous word + // Meta-f -> move to end of next word + // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + .. + } => { + self.set_cursor(self.beginning_of_previous_word()); + } + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + .. + } => { + self.set_cursor(self.end_of_next_word()); + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_beginning_of_line(); + } + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_end_of_line(); + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.yank(); + } + + // Cursor movement + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_left(); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_right(); + } + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_left(); + } + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_right(); + } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_down(); + } + // Some terminals send Alt+Arrow for word-wise movement: + // Option/Left -> Alt+Left (previous word start) + // Option/Right -> Alt+Right (next word end) + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.beginning_of_previous_word()); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.end_of_next_word()); + } + KeyEvent { + code: KeyCode::Up, .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Down, + .. + } => { + self.move_cursor_down(); + } + KeyEvent { + code: KeyCode::Home, + .. + } => { + self.move_cursor_to_beginning_of_line(false); + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_beginning_of_line(true); + } + + KeyEvent { + code: KeyCode::End, .. + } => { + self.move_cursor_to_end_of_line(false); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_end_of_line(true); + } + _o => { + #[cfg(feature = "debug-logs")] + tracing::debug!("Unhandled key event in TextArea: {:?}", _o); + } + } + } + + // ####### Input Functions ####### + pub fn delete_backward(&mut self, n: usize) { + if n == 0 || self.cursor_pos == 0 { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.prev_atomic_boundary(target); + if target == 0 { + break; + } + } + self.replace_range(target..self.cursor_pos, ""); + } + + pub fn delete_forward(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.next_atomic_boundary(target); + if target >= self.text.len() { + break; + } + } + self.replace_range(self.cursor_pos..target, ""); + } + + pub fn delete_backward_word(&mut self) { + let start = self.beginning_of_previous_word(); + self.kill_range(start..self.cursor_pos); + } + + /// Delete text to the right of the cursor using "word" semantics. + /// + /// Deletes from the current cursor position through the end of the next word as determined + /// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that + /// word is included in the deletion. + pub fn delete_forward_word(&mut self) { + let end = self.end_of_next_word(); + if end > self.cursor_pos { + self.kill_range(self.cursor_pos..end); + } + } + + pub fn kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + let range = if self.cursor_pos == eol { + if eol < self.text.len() { + Some(self.cursor_pos..eol + 1) + } else { + None + } + } else { + Some(self.cursor_pos..eol) + }; + + if let Some(range) = range { + self.kill_range(range); + } + } + + pub fn kill_to_beginning_of_line(&mut self) { + let bol = self.beginning_of_current_line(); + let range = if self.cursor_pos == bol { + if bol > 0 { Some(bol - 1..bol) } else { None } + } else { + Some(bol..self.cursor_pos) + }; + + if let Some(range) = range { + self.kill_range(range); + } + } + + pub fn yank(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn kill_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + + let removed = self.text[range.clone()].to_string(); + if removed.is_empty() { + return; + } + + self.kill_buffer = removed; + self.replace_range_raw(range, ""); + } + + /// Move the cursor left by a single grapheme cluster. + pub fn move_cursor_left(&mut self) { + self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); + self.preferred_col = None; + } + + /// Move the cursor right by a single grapheme cluster. + pub fn move_cursor_right(&mut self) { + self.cursor_pos = self.next_atomic_boundary(self.cursor_pos); + self.preferred_col = None; + } + + pub fn move_cursor_up(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, maybe_line)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx > 0 { + let prev = &lines[idx - 1]; + let line_start = prev.start; + let line_end = prev.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + // We had wrapping info. Apply movement accordingly. + match maybe_line { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already at first visual line -> move to start + self.cursor_pos = 0; + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0); + let prev_line_end = prev_nl; + self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); + } else { + self.cursor_pos = 0; + self.preferred_col = None; + } + } + + pub fn move_cursor_down(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, move_to_last)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx + 1 < lines.len() { + let next = &lines[idx + 1]; + let line_start = next.start; + let line_end = next.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + match move_to_last { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already on last visual line -> move to end + self.cursor_pos = self.text.len(); + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + if let Some(next_nl) = self.text[self.cursor_pos..] + .find('\n') + .map(|i| i + self.cursor_pos) + { + let next_line_start = next_nl + 1; + let next_line_end = self.text[next_line_start..] + .find('\n') + .map(|i| i + next_line_start) + .unwrap_or(self.text.len()); + self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); + } else { + self.cursor_pos = self.text.len(); + self.preferred_col = None; + } + } + + pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { + let bol = self.beginning_of_current_line(); + if move_up_at_bol && self.cursor_pos == bol { + self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); + } else { + self.set_cursor(bol); + } + self.preferred_col = None; + } + + pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { + let eol = self.end_of_current_line(); + if move_down_at_eol && self.cursor_pos == eol { + let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); + self.set_cursor(self.end_of_line(next_pos)); + } else { + self.set_cursor(eol); + } + } + + // ===== Text elements support ===== + + pub fn insert_element(&mut self, text: &str) { + let start = self.clamp_pos_for_insertion(self.cursor_pos); + self.insert_str_at(start, text); + let end = start + text.len(); + self.add_element(start..end); + // Place cursor at end of inserted element + self.set_cursor(end); + } + + fn add_element(&mut self, range: Range) { + let elem = TextElement { range }; + self.elements.push(elem); + self.elements.sort_by_key(|e| e.range.start); + } + + fn find_element_containing(&self, pos: usize) -> Option { + self.elements + .iter() + .position(|e| pos > e.range.start && pos < e.range.end) + } + + fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize { + if pos > self.text.len() { + pos = self.text.len(); + } + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + let dist_start = pos.saturating_sub(e.range.start); + let dist_end = e.range.end.saturating_sub(pos); + if dist_start <= dist_end { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + fn clamp_pos_for_insertion(&self, pos: usize) -> usize { + // Do not allow inserting into the middle of an element + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + // Choose closest edge for insertion + let dist_start = pos.saturating_sub(e.range.start); + let dist_end = e.range.end.saturating_sub(pos); + if dist_start <= dist_end { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { + // Expand to include any intersecting elements fully + loop { + let mut changed = false; + for e in &self.elements { + if e.range.start < range.end && e.range.end > range.start { + let new_start = range.start.min(e.range.start); + let new_end = range.end.max(e.range.end); + if new_start != range.start || new_end != range.end { + range.start = new_start; + range.end = new_end; + changed = true; + } + } + } + if !changed { + break; + } + } + range + } + + fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) { + // Generic shift: for pure insert, removed = 0; for delete, inserted = 0. + let end = at + removed; + let diff = inserted as isize - removed as isize; + // Remove elements fully deleted by the operation and shift the rest + self.elements + .retain(|e| !(e.range.start >= at && e.range.end <= end)); + for e in &mut self.elements { + if e.range.end <= at { + // before edit + } else if e.range.start >= end { + // after edit + e.range.start = ((e.range.start as isize) + diff) as usize; + e.range.end = ((e.range.end as isize) + diff) as usize; + } else { + // Overlap with element but not fully contained (shouldn't happen when using + // element-aware replace, but degrade gracefully by snapping element to new bounds) + let new_start = at.min(e.range.start); + let new_end = at + inserted.max(e.range.end.saturating_sub(end)); + e.range.start = new_start; + e.range.end = new_end; + } + } + } + + fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) { + self.shift_elements(start, end.saturating_sub(start), inserted_len); + } + + fn prev_atomic_boundary(&self, pos: usize) -> usize { + if pos == 0 { + return 0; + } + // If currently at an element end or inside, jump to start of that element. + if let Some(idx) = self + .elements + .iter() + .position(|e| pos > e.range.start && pos <= e.range.end) + { + return self.elements[idx].range.start; + } + let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); + match gc.prev_boundary(&self.text, 0) { + Ok(Some(b)) => { + if let Some(idx) = self.find_element_containing(b) { + self.elements[idx].range.start + } else { + b + } + } + Ok(None) => 0, + Err(_) => pos.saturating_sub(1), + } + } + + fn next_atomic_boundary(&self, pos: usize) -> usize { + if pos >= self.text.len() { + return self.text.len(); + } + // If currently at an element start or inside, jump to end of that element. + if let Some(idx) = self + .elements + .iter() + .position(|e| pos >= e.range.start && pos < e.range.end) + { + return self.elements[idx].range.end; + } + let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); + match gc.next_boundary(&self.text, 0) { + Ok(Some(b)) => { + if let Some(idx) = self.find_element_containing(b) { + self.elements[idx].range.end + } else { + b + } + } + Ok(None) => self.text.len(), + Err(_) => pos.saturating_add(1), + } + } + + pub(crate) fn beginning_of_previous_word(&self) -> usize { + let prefix = &self.text[..self.cursor_pos]; + let Some((first_non_ws_idx, ch)) = prefix + .char_indices() + .rev() + .find(|&(_, ch)| !ch.is_whitespace()) + else { + return 0; + }; + let is_separator = is_word_separator(ch); + let mut start = first_non_ws_idx; + for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + start = idx + ch.len_utf8(); + break; + } + start = idx; + } + self.adjust_pos_out_of_elements(start, true) + } + + pub(crate) fn end_of_next_word(&self) -> usize { + let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) + else { + return self.text.len(); + }; + let word_start = self.cursor_pos + first_non_ws; + let mut iter = self.text[word_start..].char_indices(); + let Some((_, first_ch)) = iter.next() else { + return word_start; + }; + let is_separator = is_word_separator(first_ch); + let mut end = self.text.len(); + for (idx, ch) in iter { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + end = word_start + idx; + break; + } + } + self.adjust_pos_out_of_elements(end, false) + } + + fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { + if let Some(idx) = self.find_element_containing(pos) { + let e = &self.elements[idx]; + if prefer_start { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + #[expect(clippy::unwrap_used)] + fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { + // Ensure cache is ready (potentially mutably borrow, then drop) + { + let mut cache = self.wrap_cache.borrow_mut(); + let needs_recalc = match cache.as_ref() { + Some(c) => c.width != width, + None => true, + }; + if needs_recalc { + let lines = crate::wrapping::wrap_ranges( + &self.text, + Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ); + *cache = Some(WrapCache { width, lines }); + } + } + + let cache = self.wrap_cache.borrow(); + Ref::map(cache, |c| &c.as_ref().unwrap().lines) + } + + /// Calculate the scroll offset that should be used to satisfy the + /// invariants given the current area size and wrapped lines. + /// + /// - Cursor is always on screen. + /// - No scrolling if content fits in the area. + fn effective_scroll( + &self, + area_height: u16, + lines: &[Range], + current_scroll: u16, + ) -> u16 { + let total_lines = lines.len() as u16; + if area_height >= total_lines { + return 0; + } + + // Where is the cursor within wrapped lines? Prefer assigning boundary positions + // (where pos equals the start of a wrapped line) to that later line. + let cursor_line_idx = + Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; + + let max_scroll = total_lines.saturating_sub(area_height); + let mut scroll = current_scroll.min(max_scroll); + + // Ensure cursor is visible within [scroll, scroll + area_height) + if cursor_line_idx < scroll { + scroll = cursor_line_idx; + } else if cursor_line_idx >= scroll + area_height { + scroll = cursor_line_idx + 1 - area_height; + } + scroll + } +} + +impl WidgetRef for &TextArea { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + self.render_lines(area, buf, &lines, 0..lines.len()); + } +} + +impl StatefulWidgetRef for &TextArea { + type State = TextAreaState; + + fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + self.render_lines(area, buf, &lines, start..end); + } +} + +impl TextArea { + fn render_lines( + &self, + area: Rect, + buf: &mut Buffer, + lines: &[Range], + range: std::ops::Range, + ) { + for (row, idx) in range.enumerate() { + let r = &lines[idx]; + let y = area.y + row as u16; + let line_range = r.start..r.end - 1; + // Draw base line with default style. + buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); + + // Overlay styled segments for elements that intersect this line. + for elem in &self.elements { + // Compute overlap with displayed slice. + let overlap_start = elem.range.start.max(line_range.start); + let overlap_end = elem.range.end.min(line_range.end); + if overlap_start >= overlap_end { + continue; + } + let styled = &self.text[overlap_start..overlap_end]; + let x_off = self.text[line_range.start..overlap_start].width() as u16; + let style = Style::default().fg(Color::Cyan); + buf.set_string(area.x + x_off, y, styled, style); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + // crossterm types are intentionally not imported here to avoid unused warnings + use rand::prelude::*; + + fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { + let r: u8 = rng.random_range(0..100); + match r { + 0..=4 => "\n".to_string(), + 5..=12 => " ".to_string(), + 13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(), + 36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(), + 46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(), + 53..=65 => { + // Some emoji (wide graphemes) + let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"]; + choices[rng.random_range(0..choices.len())].to_string() + } + 66..=75 => { + // CJK wide characters + let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"]; + choices[rng.random_range(0..choices.len())].to_string() + } + 76..=85 => { + // Combining mark sequences + let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)]; + let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"]; + format!("{base}{}", marks[rng.random_range(0..marks.len())]) + } + 86..=92 => { + // Some non-latin single codepoints (Greek, Cyrillic, Hebrew) + let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"]; + choices[rng.random_range(0..choices.len())].to_string() + } + _ => { + // ZWJ sequences (single graphemes but multi-codepoint) + let choices = [ + "👩\u{200D}💻", // woman technologist + "👨\u{200D}💻", // man technologist + "🏳️\u{200D}🌈", // rainbow flag + ]; + choices[rng.random_range(0..choices.len())].to_string() + } + } + } + + fn ta_with(text: &str) -> TextArea { + let mut t = TextArea::new(); + t.insert_str(text); + t + } + + #[test] + fn insert_and_replace_update_cursor_and_text() { + // insert helpers + let mut t = ta_with("hello"); + t.set_cursor(5); + t.insert_str("!"); + assert_eq!(t.text(), "hello!"); + assert_eq!(t.cursor(), 6); + + t.insert_str_at(0, "X"); + assert_eq!(t.text(), "Xhello!"); + assert_eq!(t.cursor(), 7); + + // Insert after the cursor should not move it + t.set_cursor(1); + let end = t.text().len(); + t.insert_str_at(end, "Y"); + assert_eq!(t.text(), "Xhello!Y"); + assert_eq!(t.cursor(), 1); + + // replace_range cases + // 1) cursor before range + let mut t = ta_with("abcd"); + t.set_cursor(1); + t.replace_range(2..3, "Z"); + assert_eq!(t.text(), "abZd"); + assert_eq!(t.cursor(), 1); + + // 2) cursor inside range + let mut t = ta_with("abcd"); + t.set_cursor(2); + t.replace_range(1..3, "Q"); + assert_eq!(t.text(), "aQd"); + assert_eq!(t.cursor(), 2); + + // 3) cursor after range with shifted by diff + let mut t = ta_with("abcd"); + t.set_cursor(4); + t.replace_range(0..1, "AA"); + assert_eq!(t.text(), "AAbcd"); + assert_eq!(t.cursor(), 5); + } + + #[test] + fn delete_backward_and_forward_edges() { + let mut t = ta_with("abc"); + t.set_cursor(1); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // deleting backward at start is a no-op + t.set_cursor(0); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // forward delete removes next grapheme + t.set_cursor(1); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + assert_eq!(t.cursor(), 1); + + // forward delete at end is a no-op + t.set_cursor(t.text().len()); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + } + + #[test] + fn delete_backward_word_and_kill_line_variants() { + // delete backward word at end removes the whole previous word + let mut t = ta_with("hello world "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 8); + + // From inside a word, delete from word start to cursor + let mut t = ta_with("foo bar"); + t.set_cursor(6); // inside "bar" (after 'a') + t.delete_backward_word(); + assert_eq!(t.text(), "foo r"); + assert_eq!(t.cursor(), 4); + + // From end, delete the last word only + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + + // kill_to_end_of_line when not at EOL + let mut t = ta_with("abc\ndef"); + t.set_cursor(1); // on first line, middle + t.kill_to_end_of_line(); + assert_eq!(t.text(), "a\ndef"); + assert_eq!(t.cursor(), 1); + + // kill_to_end_of_line when at EOL deletes newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(3); // EOL of first line + t.kill_to_end_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + + // kill_to_beginning_of_line from middle of line + let mut t = ta_with("abc\ndef"); + t.set_cursor(5); // on second line, after 'e' + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abc\nef"); + + // kill_to_beginning_of_line at beginning of non-first line removes the previous newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(4); // beginning of second line + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + } + + #[test] + fn delete_forward_word_variants() { + let mut t = ta_with("hello world "); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " world "); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("hello world "); + t.set_cursor(1); + t.delete_forward_word(); + assert_eq!(t.text(), "h world "); + assert_eq!(t.cursor(), 1); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_forward_word(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo \nbar"); + t.set_cursor(3); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo\nbar"); + t.set_cursor(3); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("hello world "); + t.set_cursor(t.text().len() + 10); + t.delete_forward_word(); + assert_eq!(t.text(), "hello world "); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn delete_forward_word_handles_atomic_elements() { + let mut t = TextArea::new(); + t.insert_element(""); + t.insert_str(" tail"); + + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " tail"); + assert_eq!(t.cursor(), 0); + + let mut t = TextArea::new(); + t.insert_str(" "); + t.insert_element(""); + t.insert_str(" tail"); + + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " tail"); + assert_eq!(t.cursor(), 0); + + let mut t = TextArea::new(); + t.insert_str("prefix "); + t.insert_element(""); + t.insert_str(" tail"); + + // cursor in the middle of the element, delete_forward_word deletes the element + let elem_range = t.elements[0].range.clone(); + t.cursor_pos = elem_range.start + (elem_range.len() / 2); + t.delete_forward_word(); + assert_eq!(t.text(), "prefix tail"); + assert_eq!(t.cursor(), elem_range.start); + } + + #[test] + fn delete_backward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "path/to/"); + assert_eq!(t.cursor(), t.text().len()); + + t.delete_backward_word(); + assert_eq!(t.text(), "path/to"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo/ "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo /"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + } + + #[test] + fn delete_forward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "/to/file"); + assert_eq!(t.cursor(), 0); + + t.delete_forward_word(); + assert_eq!(t.text(), "to/file"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("/ foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " foo"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with(" /foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn yank_restores_last_kill() { + let mut t = ta_with("hello"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + t.yank(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), 11); + + let mut t = ta_with("hello"); + t.set_cursor(5); + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + } + + #[test] + fn cursor_left_and_right_handle_graphemes() { + let mut t = ta_with("a👍b"); + t.set_cursor(t.text().len()); + + t.move_cursor_left(); // before 'b' + let after_first_left = t.cursor(); + t.move_cursor_left(); // before '👍' + let after_second_left = t.cursor(); + t.move_cursor_left(); // before 'a' + let after_third_left = t.cursor(); + + assert!(after_first_left < t.text().len()); + assert!(after_second_left < after_first_left); + assert!(after_third_left < after_second_left); + + // Move right back to end safely + t.move_cursor_right(); + t.move_cursor_right(); + t.move_cursor_right(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn control_b_and_f_move_cursor() { + let mut t = ta_with("abcd"); + t.set_cursor(1); + + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn control_b_f_fallback_control_chars_move_cursor() { + let mut t = ta_with("abcd"); + t.set_cursor(2); + + // Simulate terminals that send C0 control chars without CONTROL modifier. + // ^B (U+0002) should move left + t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 1); + + // ^F (U+0006) should move right + t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + } + + #[test] + fn delete_backward_word_alt_keys() { + // Test the custom Alt+Ctrl+h binding + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); // cursor at the end + t.input(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + // Test the standard Alt+Backspace binding + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); // cursor at the end + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn delete_backward_word_handles_narrow_no_break_space() { + let mut t = ta_with("32\u{202F}AM"); + t.set_cursor(t.text().len()); + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); + pretty_assertions::assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn delete_forward_word_with_without_alt_modifier() { + let mut t = ta_with("hello world"); + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT)); + assert_eq!(t.text(), " world"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("hello"); + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(t.text(), "ello"); + assert_eq!(t.cursor(), 0); + } + + #[test] + fn control_h_backspace() { + // Test Ctrl+H as backspace + let mut t = ta_with("12345"); + t.set_cursor(3); // cursor after '3' + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "1245"); + assert_eq!(t.cursor(), 2); + + // Test Ctrl+H at beginning (should be no-op) + t.set_cursor(0); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "1245"); + assert_eq!(t.cursor(), 0); + + // Test Ctrl+H at end + t.set_cursor(t.text().len()); + t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(t.text(), "124"); + assert_eq!(t.cursor(), 3); + } + + #[cfg_attr(not(windows), ignore = "AltGr modifier only applies on Windows")] + #[test] + fn altgr_ctrl_alt_char_inserts_literal() { + let mut t = ta_with(""); + t.input(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(t.text(), "c"); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn cursor_vertical_movement_across_lines_and_bounds() { + let mut t = ta_with("short\nloooooooooong\nmid"); + // Place cursor on second line, column 5 + let second_line_start = 6; // after first '\n' + t.set_cursor(second_line_start + 5); + + // Move up: target column preserved, clamped by line length + t.move_cursor_up(); + assert_eq!(t.cursor(), 5); // first line has len 5 + + // Move up again goes to start of text + t.move_cursor_up(); + assert_eq!(t.cursor(), 0); + + // Move down: from start to target col tracked + t.move_cursor_down(); + // On first move down, we should land on second line, at col 0 (target col remembered as 0) + let pos_after_down = t.cursor(); + assert!(pos_after_down >= second_line_start); + + // Move down again to third line; clamp to its length + t.move_cursor_down(); + let third_line_start = t.text().find("mid").unwrap(); + let third_line_end = third_line_start + 3; + assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end); + + // Moving down at last line jumps to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn home_end_and_emacs_style_home_end() { + let mut t = ta_with("one\ntwo\nthree"); + // Position at middle of second line + let second_line_start = t.text().find("two").unwrap(); + t.set_cursor(second_line_start + 1); + + t.move_cursor_to_beginning_of_line(false); + assert_eq!(t.cursor(), second_line_start); + + // Ctrl-A behavior: if at BOL, go to beginning of previous line + t.move_cursor_to_beginning_of_line(true); + assert_eq!(t.cursor(), 0); // beginning of first line + + // Move to EOL of first line + t.move_cursor_to_end_of_line(false); + assert_eq!(t.cursor(), 3); + + // Ctrl-E: if at EOL, go to end of next line + t.move_cursor_to_end_of_line(true); + // end of second line ("two") is right before its '\n' + let end_second_nl = t.text().find("\nthree").unwrap(); + assert_eq!(t.cursor(), end_second_nl); + } + + #[test] + fn end_of_line_or_down_at_end_of_text() { + let mut t = ta_with("one\ntwo"); + // Place cursor at absolute end of the text + t.set_cursor(t.text().len()); + // Should remain at end without panicking + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); + + // Also verify behavior when at EOL of a non-final line: + let eol_first_line = 3; // index of '\n' in "one\ntwo" + t.set_cursor(eol_first_line); + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line + } + + #[test] + fn word_navigation_helpers() { + let t = ta_with(" alpha beta gamma"); + let mut t = t; // make mutable for set_cursor + // Put cursor after "alpha" + let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); + t.set_cursor(after_alpha); + assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces + + // Put cursor at start of beta + let beta_start = t.text().find("beta").unwrap(); + t.set_cursor(beta_start); + assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); + + // If at end, end_of_next_word returns len + t.set_cursor(t.text().len()); + assert_eq!(t.end_of_next_word(), t.text().len()); + } + + #[test] + fn wrapping_and_cursor_positions() { + let mut t = ta_with("hello world here"); + let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words + // desired height counts wrapped lines + assert!(t.desired_height(area.width) >= 3); + + // Place cursor in "world" + let world_start = t.text().find("world").unwrap(); + t.set_cursor(world_start + 3); + let (_x, y) = t.cursor_pos(area).unwrap(); + assert_eq!(y, 1); // world should be on second wrapped line + + // With state and small height, cursor is mapped onto visible row + let mut state = TextAreaState::default(); + let small_area = Rect::new(0, 0, 6, 1); + // First call: cursor not visible -> effective scroll ensures it is + let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); + assert_eq!(y, 0); + + // Render with state to update actual scroll value + let mut buf = Buffer::empty(small_area); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state); + // After render, state.scroll should be adjusted so cursor row fits + let effective_lines = t.desired_height(small_area.width); + assert!(state.scroll < effective_lines); + } + + #[test] + fn cursor_pos_with_state_basic_and_scroll_behaviors() { + // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. + let mut t = ta_with("hello world"); + t.set_cursor(3); + let area = Rect::new(2, 5, 20, 3); + // Even if an absurd scroll is provided, when content fits the area the + // effective scroll is 0 and the cursor position matches cursor_pos. + let bad_state = TextAreaState { scroll: 999 }; + let (x1, y1) = t.cursor_pos(area).unwrap(); + let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap(); + assert_eq!((x2, y2), (x1, y1)); + + // Case 2: Cursor below the current window — y should be clamped to the + // bottom row (area.height - 1) after adjusting effective scroll. + let mut t = ta_with("one two three four five six"); + // Force wrapping to many visual lines. + let wrap_width = 4; + let _ = t.desired_height(wrap_width); + // Put cursor somewhere near the end so it's definitely below the first window. + t.set_cursor(t.text().len().saturating_sub(2)); + let small_area = Rect::new(0, 0, wrap_width, 2); + let state = TextAreaState { scroll: 0 }; + let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); + assert_eq!(y, small_area.y + small_area.height - 1); + + // Case 3: Cursor above the current window — y should be top row (0) + // when the provided scroll is too large. + let mut t = ta_with("alpha beta gamma delta epsilon zeta"); + let wrap_width = 5; + let lines = t.desired_height(wrap_width); + // Place cursor near start so an excessive scroll moves it to top row. + t.set_cursor(1); + let area = Rect::new(0, 0, wrap_width, 3); + let state = TextAreaState { + scroll: lines.saturating_mul(2), + }; + let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!(y, area.y); + } + + #[test] + fn wrapped_navigation_across_visual_lines() { + let mut t = ta_with("abcdefghij"); + // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"] + let _ = t.desired_height(4); + + // From the very start, moving down should go to the start of the next wrapped line (index 4) + t.set_cursor(0); + t.move_cursor_down(); + assert_eq!(t.cursor(), 4); + + // Cursor at boundary index 4 should be displayed at start of second wrapped line + t.set_cursor(4); + let area = Rect::new(0, 0, 4, 10); + let (x, y) = t.cursor_pos(area).unwrap(); + assert_eq!((x, y), (0, 1)); + + // With state and small height, cursor should be visible at row 0, col 0 + let small_area = Rect::new(0, 0, 4, 1); + let state = TextAreaState::default(); + let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' + t.set_cursor(6); + // Move up should go to same column on previous wrapped line -> index 2 ('c') + t.move_cursor_up(); + assert_eq!(t.cursor(), 2); + + // Move down should return to same position on the next wrapped line -> back to index 6 ('g') + t.move_cursor_down(); + assert_eq!(t.cursor(), 6); + + // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn cursor_pos_with_state_after_movements() { + let mut t = ta_with("abcdefghij"); + // Wrap width 4 -> visual lines: abcd | efgh | ij + let _ = t.desired_height(4); + let area = Rect::new(0, 0, 4, 2); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(area); + + // Start at beginning + t.set_cursor(0); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x, y), (0, 1)); + + // Move down to third visual line; viewport scrolls and keeps cursor on bottom row + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x, y), (0, 1)); + + // Move up to second visual line; with current scroll, it appears on top row + t.move_cursor_up(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Column preservation across moves: set to col 2 on first line, move down + t.set_cursor(2); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x0, y0), (2, 0)); + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap(); + assert_eq!((x1, y1), (2, 1)); + } + + #[test] + fn wrapped_navigation_with_newlines_and_spaces() { + // Include spaces and an explicit newline to exercise boundaries + let mut t = ta_with("word1 word2\nword3"); + // Width 6 will wrap "word1 " and then "word2" before the newline + let _ = t.desired_height(6); + + // Put cursor on the second wrapped line before the newline, at column 1 of "word2" + let start_word2 = t.text().find("word2").unwrap(); + t.set_cursor(start_word2 + 1); + + // Up should go to first wrapped line, column 1 -> index 1 + t.move_cursor_up(); + assert_eq!(t.cursor(), 1); + + // Down should return to the same visual column on "word2" + t.move_cursor_down(); + assert_eq!(t.cursor(), start_word2 + 1); + + // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed + t.move_cursor_down(); + let start_word3 = t.text().find("word3").unwrap(); + assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); + } + + #[test] + fn wrapped_navigation_with_wide_graphemes() { + // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries + let mut t = ta_with("👍👍👍👍"); + let _ = t.desired_height(3); + + // Put cursor after the second emoji (which should be on first wrapped line) + t.set_cursor("👍👍".len()); + + // Move down should go to the start of the next wrapped line (same column preserved but clamped) + t.move_cursor_down(); + // We expect to land somewhere within the third emoji or at the start of it + let pos_after_down = t.cursor(); + assert!(pos_after_down >= "👍👍".len()); + + // Moving up should take us back to the original position + t.move_cursor_up(); + assert_eq!(t.cursor(), "👍👍".len()); + } + + #[test] + fn fuzz_textarea_randomized() { + // Deterministic seed for reproducibility + // Seed the RNG based on the current day in Pacific Time (PST/PDT). This + // keeps the fuzz test deterministic within a day while still varying + // day-to-day to improve coverage. + let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8)) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp() as u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed); + + for _case in 0..500 { + let mut ta = TextArea::new(); + let mut state = TextAreaState::default(); + // Track element payloads we insert. Payloads use characters '[' and ']' which + // are not produced by rand_grapheme(), avoiding accidental collisions. + let mut elem_texts: Vec = Vec::new(); + let mut next_elem_id: usize = 0; + // Start with a random base string + let base_len = rng.random_range(0..30); + let mut base = String::new(); + for _ in 0..base_len { + base.push_str(&rand_grapheme(&mut rng)); + } + ta.set_text(&base); + // Choose a valid char boundary for initial cursor + let mut boundaries: Vec = vec![0]; + boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + boundaries.push(ta.text().len()); + let init = boundaries[rng.random_range(0..boundaries.len())]; + ta.set_cursor(init); + + let mut width: u16 = rng.random_range(1..=12); + let mut height: u16 = rng.random_range(1..=4); + + for _step in 0..60 { + // Mostly stable width/height, occasionally change + if rng.random_bool(0.1) { + width = rng.random_range(1..=12); + } + if rng.random_bool(0.1) { + height = rng.random_range(1..=4); + } + + // Pick an operation + match rng.random_range(0..18) { + 0 => { + // insert small random string at cursor + let len = rng.random_range(0..6); + let mut s = String::new(); + for _ in 0..len { + s.push_str(&rand_grapheme(&mut rng)); + } + ta.insert_str(&s); + } + 1 => { + // replace_range with small random slice + let mut b: Vec = vec![0]; + b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + b.push(ta.text().len()); + let i1 = rng.random_range(0..b.len()); + let i2 = rng.random_range(0..b.len()); + let (start, end) = if b[i1] <= b[i2] { + (b[i1], b[i2]) + } else { + (b[i2], b[i1]) + }; + let insert_len = rng.random_range(0..=4); + let mut s = String::new(); + for _ in 0..insert_len { + s.push_str(&rand_grapheme(&mut rng)); + } + let before = ta.text().len(); + // If the chosen range intersects an element, replace_range will expand to + // element boundaries, so the naive size delta assertion does not hold. + let intersects_element = elem_texts.iter().any(|payload| { + if let Some(pstart) = ta.text().find(payload) { + let pend = pstart + payload.len(); + pstart < end && pend > start + } else { + false + } + }); + ta.replace_range(start..end, &s); + if !intersects_element { + let after = ta.text().len(); + assert_eq!( + after as isize, + before as isize + (s.len() as isize) - ((end - start) as isize) + ); + } + } + 2 => ta.delete_backward(rng.random_range(0..=3)), + 3 => ta.delete_forward(rng.random_range(0..=3)), + 4 => ta.delete_backward_word(), + 5 => ta.kill_to_beginning_of_line(), + 6 => ta.kill_to_end_of_line(), + 7 => ta.move_cursor_left(), + 8 => ta.move_cursor_right(), + 9 => ta.move_cursor_up(), + 10 => ta.move_cursor_down(), + 11 => ta.move_cursor_to_beginning_of_line(true), + 12 => ta.move_cursor_to_end_of_line(true), + 13 => { + // Insert an element with a unique sentinel payload + let payload = + format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999)); + next_elem_id += 1; + ta.insert_element(&payload); + elem_texts.push(payload); + } + 14 => { + // Try inserting inside an existing element (should clamp to boundary) + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + let ins = rand_grapheme(&mut rng); + ta.insert_str_at(pos, &ins); + } + } + } + 15 => { + // Replace a range that intersects an element -> whole element should be replaced + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + // Create an intersecting range [start-δ, end-δ2) + let mut s = start.saturating_sub(rng.random_range(0..=2)); + let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); + // Align to char boundaries to satisfy String::replace_range contract + let txt = ta.text(); + while s > 0 && !txt.is_char_boundary(s) { + s -= 1; + } + while e < txt.len() && !txt.is_char_boundary(e) { + e += 1; + } + if s < e { + // Small replacement text + let mut srep = String::new(); + for _ in 0..rng.random_range(0..=2) { + srep.push_str(&rand_grapheme(&mut rng)); + } + ta.replace_range(s..e, &srep); + } + } + } + 16 => { + // Try setting the cursor to a position inside an element; it should clamp out + if let Some(payload) = elem_texts.choose(&mut rng).cloned() + && let Some(start) = ta.text().find(&payload) + { + let end = start + payload.len(); + if end - start > 2 { + let pos = rng.random_range(start + 1..end - 1); + ta.set_cursor(pos); + } + } + } + _ => { + // Jump to word boundaries + if rng.random_bool(0.5) { + let p = ta.beginning_of_previous_word(); + ta.set_cursor(p); + } else { + let p = ta.end_of_next_word(); + ta.set_cursor(p); + } + } + } + + // Sanity invariants + assert!(ta.cursor() <= ta.text().len()); + + // Element invariants + for payload in &elem_texts { + if let Some(start) = ta.text().find(payload) { + let end = start + payload.len(); + // 1) Text inside elements matches the initially set payload + assert_eq!(&ta.text()[start..end], payload); + // 2) Cursor is never strictly inside an element + let c = ta.cursor(); + assert!( + c <= start || c >= end, + "cursor inside element: {start}..{end} at {c}" + ); + } + } + + // Render and compute cursor positions; ensure they are in-bounds and do not panic + let area = Rect::new(0, 0, width, height); + // Stateless render into an area tall enough for all wrapped lines + let total_lines = ta.desired_height(width); + let full_area = Rect::new(0, 0, width, total_lines.max(1)); + let mut buf = Buffer::empty(full_area); + ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); + + // cursor_pos: x must be within width when present + let _ = ta.cursor_pos(area); + + // cursor_pos_with_state: always within viewport rows + let (_x, _y) = ta + .cursor_pos_with_state(area, state) + .unwrap_or((area.x, area.y)); + + // Stateful render should not panic, and updates scroll + let mut sbuf = Buffer::empty(area); + ratatui::widgets::StatefulWidgetRef::render_ref( + &(&ta), + area, + &mut sbuf, + &mut state, + ); + + // After wrapping, desired height equals the number of lines we would render without scroll + let total_lines = total_lines as usize; + // state.scroll must not exceed total_lines when content fits within area height + if (height as usize) >= total_lines { + assert_eq!(state.scroll, 0); + } + } + } + } +} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs new file mode 100644 index 0000000000..ea29c00d93 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget.rs @@ -0,0 +1,3463 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use codex_app_server_protocol::AuthMode; +use codex_backend_client::Client as BackendClient; +use codex_core::config::Config; +use codex_core::config::types::Notifications; +use codex_core::git_info::current_branch_name; +use codex_core::git_info::local_git_branches; +use codex_core::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::AgentReasoningRawContentDeltaEvent; +use codex_core::protocol::AgentReasoningRawContentEvent; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CreditsSnapshot; +use codex_core::protocol::DeprecationNoticeEvent; +use codex_core::protocol::ErrorEvent; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::McpListToolsResponseEvent; +use codex_core::protocol::McpStartupCompleteEvent; +use codex_core::protocol::McpStartupStatus; +use codex_core::protocol::McpStartupUpdateEvent; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::ReviewRequest; +use codex_core::protocol::ReviewTarget; +use codex_core::protocol::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::TurnAbortReason; +use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; +use codex_core::protocol::UserMessageEvent; +use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; +use codex_core::protocol::WebSearchBeginEvent; +use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillMetadata; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::UserInput; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use rand::Rng; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use tokio::sync::mpsc::UnboundedSender; +use tokio::task::JoinHandle; +use tracing::debug; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::BottomPane; +use crate::bottom_pane::BottomPaneParams; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::clipboard_paste::paste_image_to_temp_png; +use crate::diff_render::display_path_for; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::ExecCell; +use crate::exec_cell::new_active_exec_command; +use crate::get_git_diff::get_git_diff; +use crate::history_cell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::McpToolCallCell; +use crate::history_cell::PlainHistoryCell; +use crate::markdown::append_markdown; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt; +use crate::render::renderable::RenderableItem; +use crate::slash_command::SlashCommand; +use crate::status::RateLimitSnapshotDisplay; +use crate::text_formatting::truncate_text; +use crate::tui::FrameRequester; +mod interrupts; +use self::interrupts::InterruptManager; +mod agent; +use self::agent::spawn_agent; +use self::agent::spawn_agent_from_existing; +mod session_header; +use self::session_header::SessionHeader; +use crate::streaming::controller::StreamController; +use std::path::Path; + +use chrono::Local; +use codex_common::approval_presets::ApprovalPreset; +use codex_common::approval_presets::builtin_approval_presets; +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; +use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::UpdatePlanArgs; +use strum::IntoEnumIterator; + +const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; +const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; +// Track information about an in-flight exec command. +struct RunningCommand { + command: Vec, + parsed_cmd: Vec, + source: ExecCommandSource, +} + +struct UnifiedExecWaitState { + command_display: String, +} + +impl UnifiedExecWaitState { + fn new(command_display: String) -> Self { + Self { command_display } + } + + fn is_duplicate(&self, command_display: &str) -> bool { + self.command_display == command_display + } +} + +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; +const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; + +#[derive(Default)] +struct RateLimitWarningState { + secondary_index: usize, + primary_index: usize, +} + +impl RateLimitWarningState { + fn take_warnings( + &mut self, + secondary_used_percent: Option, + secondary_window_minutes: Option, + primary_used_percent: Option, + primary_window_minutes: Option, + ) -> Vec { + let reached_secondary_cap = + matches!(secondary_used_percent, Some(percent) if percent == 100.0); + let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); + if reached_secondary_cap || reached_primary_cap { + return Vec::new(); + } + + let mut warnings = Vec::new(); + + if let Some(secondary_used_percent) = secondary_used_percent { + let mut highest_secondary: Option = None; + while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] + { + highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); + self.secondary_index += 1; + } + if let Some(threshold) = highest_secondary { + let limit_label = secondary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + if let Some(primary_used_percent) = primary_used_percent { + let mut highest_primary: Option = None; + while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] + { + highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); + self.primary_index += 1; + } + if let Some(threshold) = highest_primary { + let limit_label = primary_window_minutes + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; + warnings.push(format!( + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + )); + } + } + + warnings + } +} + +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); + + if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { + let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); + let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); + format!("{hours}h") + } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { + "weekly".to_string() + } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { + "monthly".to_string() + } else { + "annual".to_string() + } +} + +/// Common initialization parameters shared by all `ChatWidget` constructors. +pub(crate) struct ChatWidgetInit { + pub(crate) config: Config, + pub(crate) frame_requester: FrameRequester, + pub(crate) app_event_tx: AppEventSender, + pub(crate) initial_prompt: Option, + pub(crate) initial_images: Vec, + pub(crate) enhanced_keys_supported: bool, + pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) skills: Option>, + pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, +} + +#[derive(Default)] +enum RateLimitSwitchPromptState { + #[default] + Idle, + Pending, + Shown, +} + +pub(crate) struct ChatWidget { + app_event_tx: AppEventSender, + codex_op_tx: UnboundedSender, + bottom_pane: BottomPane, + active_cell: Option>, + config: Config, + model_family: ModelFamily, + auth_manager: Arc, + models_manager: Arc, + session_header: SessionHeader, + initial_user_message: Option, + token_info: Option, + rate_limit_snapshot: Option, + plan_type: Option, + rate_limit_warnings: RateLimitWarningState, + rate_limit_switch_prompt: RateLimitSwitchPromptState, + rate_limit_poller: Option>, + // Stream lifecycle controller + stream_controller: Option, + running_commands: HashMap, + suppressed_exec_calls: HashSet, + last_unified_wait: Option, + task_complete_pending: bool, + mcp_startup_status: Option>, + // Queue of interruptive UI events deferred during an active write cycle + interrupts: InterruptManager, + // Accumulates the current reasoning block text to extract a header + reasoning_buffer: String, + // Accumulates full reasoning content for transcript-only recording + full_reasoning_buffer: String, + // Current status header shown in the status indicator. + current_status_header: String, + // Previous status header to restore after a transient stream retry. + retry_status_header: Option, + conversation_id: Option, + frame_requester: FrameRequester, + // Whether to include the initial welcome banner on session configured + show_welcome_banner: bool, + // When resuming an existing session (selected via resume picker), avoid an + // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. + suppress_session_configured_redraw: bool, + // User messages queued while a turn is in progress + queued_user_messages: VecDeque, + // Pending notification to show when unfocused on next Draw + pending_notification: Option, + // Simple review mode flag; used to adjust layout and banners. + is_review_mode: bool, + // Snapshot of token usage to restore after review mode exits. + pre_review_token_info: Option>, + // Whether to add a final message separator after the last message + needs_final_message_separator: bool, + + last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, + // Current session rollout path (if known) + current_rollout_path: Option, +} + +struct UserMessage { + text: String, + image_paths: Vec, +} + +impl From for UserMessage { + fn from(text: String) -> Self { + Self { + text, + image_paths: Vec::new(), + } + } +} + +impl From<&str> for UserMessage { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + image_paths: Vec::new(), + } + } +} + +fn create_initial_user_message(text: String, image_paths: Vec) -> Option { + if text.is_empty() && image_paths.is_empty() { + None + } else { + Some(UserMessage { text, image_paths }) + } +} + +impl ChatWidget { + fn flush_answer_stream_with_separator(&mut self) { + if let Some(mut controller) = self.stream_controller.take() + && let Some(cell) = controller.finalize() + { + self.add_boxed_history(cell); + } + } + + fn set_status_header(&mut self, header: String) { + self.current_status_header = header.clone(); + self.bottom_pane.update_status_header(header); + } + + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + self.set_status_header(header); + } + } + + // --- Small event handlers --- + fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { + self.bottom_pane + .set_history_metadata(event.history_log_id, event.history_entry_count); + self.conversation_id = Some(event.session_id); + self.current_rollout_path = Some(event.rollout_path.clone()); + let initial_messages = event.initial_messages.clone(); + let model_for_header = event.model.clone(); + self.session_header.set_model(&model_for_header); + self.add_to_history(history_cell::new_session_info( + &self.config, + &model_for_header, + event, + self.show_welcome_banner, + )); + if let Some(messages) = initial_messages { + self.replay_initial_messages(messages); + } + // Ask codex-core to enumerate custom prompts for this session. + self.submit_op(Op::ListCustomPrompts); + if let Some(user_message) = self.initial_user_message.take() { + self.submit_user_message(user_message); + } + if !self.suppress_session_configured_redraw { + self.request_redraw(); + } + } + + pub(crate) fn open_feedback_note( + &mut self, + category: crate::app_event::FeedbackCategory, + include_logs: bool, + ) { + // Build a fresh snapshot at the time of opening the note overlay. + let snapshot = self.feedback.snapshot(self.conversation_id); + let rollout = if include_logs { + self.current_rollout_path.clone() + } else { + None + }; + let view = crate::bottom_pane::FeedbackNoteView::new( + category, + snapshot, + rollout, + self.app_event_tx.clone(), + include_logs, + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { + let params = crate::bottom_pane::feedback_upload_consent_params( + self.app_event_tx.clone(), + category, + self.current_rollout_path.clone(), + ); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + + fn on_agent_message(&mut self, message: String) { + // If we have a stream_controller, then the final agent message is redundant and will be a + // duplicate of what has already been streamed. + if self.stream_controller.is_none() { + self.handle_streaming_delta(message); + } + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); + self.request_redraw(); + } + + fn on_agent_message_delta(&mut self, delta: String) { + self.handle_streaming_delta(delta); + } + + fn on_agent_reasoning_delta(&mut self, delta: String) { + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.set_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.request_redraw(); + } + + fn on_agent_reasoning_final(&mut self) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; + // At the end of a reasoning block, record transcript-only content. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + if !self.full_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_summary_block( + self.full_reasoning_buffer.clone(), + reasoning_summary_format, + ); + self.add_boxed_history(cell); + } + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.request_redraw(); + } + + fn on_reasoning_section_break(&mut self) { + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); + self.reasoning_buffer.clear(); + } + + // Raw reasoning uses the same flow as summarized reasoning + + fn on_task_started(&mut self) { + self.bottom_pane.clear_ctrl_c_quit_hint(); + self.bottom_pane.set_task_running(true); + self.retry_status_header = None; + self.bottom_pane.set_interrupt_hint_visible(true); + self.set_status_header(String::from("Working")); + self.full_reasoning_buffer.clear(); + self.reasoning_buffer.clear(); + self.request_redraw(); + } + + fn on_task_complete(&mut self, last_agent_message: Option) { + // If a stream is currently active, finalize it. + self.flush_answer_stream_with_separator(); + // Mark task stopped and request redraw now that all content is in history. + self.bottom_pane.set_task_running(false); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.request_redraw(); + + // If there is a queued user message, send exactly one now to begin the next turn. + self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). + self.notify(Notification::AgentTurnComplete { + response: last_agent_message.unwrap_or_default(), + }); + + self.maybe_show_pending_rate_limit_prompt(); + } + + pub(crate) fn set_token_info(&mut self, info: Option) { + match info { + Some(info) => self.apply_token_info(info), + None => { + self.bottom_pane.set_context_window(None, None); + self.token_info = None; + } + } + } + + fn apply_token_info(&mut self, info: TokenUsageInfo) { + let percent = self.context_remaining_percent(&info); + let used_tokens = self.context_used_tokens(&info, percent.is_some()); + self.bottom_pane.set_context_window(percent, used_tokens); + self.token_info = Some(info); + } + + fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { + info.model_context_window + .or(self.model_family.context_window) + .map(|window| { + info.last_token_usage + .percent_of_context_window_remaining(window) + }) + } + + fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { + if percent_known { + return None; + } + + Some(info.total_token_usage.tokens_in_context_window()) + } + + fn restore_pre_review_token_info(&mut self) { + if let Some(saved) = self.pre_review_token_info.take() { + match saved { + Some(info) => self.apply_token_info(info), + None => { + self.bottom_pane.set_context_window(None, None); + self.token_info = None; + } + } + } + } + + pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(mut snapshot) = snapshot { + if snapshot.credits.is_none() { + snapshot.credits = self + .rate_limit_snapshot + .as_ref() + .and_then(|display| display.credits.as_ref()) + .map(|credits| CreditsSnapshot { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance.clone(), + }); + } + + self.plan_type = snapshot.plan_type.or(self.plan_type); + + let warnings = self.rate_limit_warnings.take_warnings( + snapshot + .secondary + .as_ref() + .map(|window| window.used_percent), + snapshot + .secondary + .as_ref() + .and_then(|window| window.window_minutes), + snapshot.primary.as_ref().map(|window| window.used_percent), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_minutes), + ); + + let high_usage = snapshot + .secondary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false) + || snapshot + .primary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false); + + if high_usage + && !self.rate_limit_switch_prompt_hidden() + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG + && !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; + } + + let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); + self.rate_limit_snapshot = Some(display); + + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } else { + self.rate_limit_snapshot = None; + } + } + /// Finalize any active exec as failed and stop/clear running UI state. + fn finalize_turn(&mut self) { + // Ensure any spinner is replaced by a red ✗ and flushed into history. + self.finalize_active_cell_as_failed(); + // Reset running state and clear streaming buffers. + self.bottom_pane.set_task_running(false); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.stream_controller = None; + self.maybe_show_pending_rate_limit_prompt(); + } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } + + fn on_error(&mut self, message: String) { + self.finalize_turn(); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + + fn on_warning(&mut self, message: impl Into) { + self.add_to_history(history_cell::new_warning_event(message.into())); + self.request_redraw(); + } + + fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { + let mut status = self.mcp_startup_status.take().unwrap_or_default(); + if let McpStartupStatus::Failed { error } = &ev.status { + self.on_warning(error); + } + status.insert(ev.server, ev.status); + self.mcp_startup_status = Some(status); + self.bottom_pane.set_task_running(true); + if let Some(current) = &self.mcp_startup_status { + let total = current.len(); + let mut starting: Vec<_> = current + .iter() + .filter_map(|(name, state)| { + if matches!(state, McpStartupStatus::Starting) { + Some(name) + } else { + None + } + }) + .collect(); + starting.sort(); + if let Some(first) = starting.first() { + let completed = total.saturating_sub(starting.len()); + let max_to_show = 3; + let mut to_show: Vec = starting + .iter() + .take(max_to_show) + .map(ToString::to_string) + .collect(); + if starting.len() > max_to_show { + to_show.push("…".to_string()); + } + let header = if total > 1 { + format!( + "Starting MCP servers ({completed}/{total}): {}", + to_show.join(", ") + ) + } else { + format!("Booting MCP server: {first}") + }; + self.set_status_header(header); + } + } + self.request_redraw(); + } + + fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { + let mut parts = Vec::new(); + if !ev.failed.is_empty() { + let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); + parts.push(format!("failed: {}", failed_servers.join(", "))); + } + if !ev.cancelled.is_empty() { + self.on_warning(format!( + "MCP startup interrupted. The following servers were not initialized: {}", + ev.cancelled.join(", ") + )); + } + if !parts.is_empty() { + self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); + } + + self.mcp_startup_status = None; + self.bottom_pane.set_task_running(false); + self.maybe_send_next_queued_input(); + self.request_redraw(); + } + + /// Handle a turn aborted due to user interrupt (Esc). + /// When there are queued user messages, restore them into the composer + /// separated by newlines rather than auto‑submitting the next one. + fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { + // Finalize, log a gentle prompt, and clear running state. + self.finalize_turn(); + + if reason != TurnAbortReason::ReviewEnded { + self.add_to_history(history_cell::new_error_event( + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), + )); + } + + // If any messages were queued during the task, restore them into the composer. + if !self.queued_user_messages.is_empty() { + let queued_text = self + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect::>() + .join("\n"); + let existing_text = self.bottom_pane.composer_text(); + let combined = if existing_text.is_empty() { + queued_text + } else if queued_text.is_empty() { + existing_text + } else { + format!("{queued_text}\n{existing_text}") + }; + self.bottom_pane.set_composer_text(combined); + // Clear the queue and update the status indicator list. + self.queued_user_messages.clear(); + self.refresh_queued_user_messages(); + } + + self.request_redraw(); + } + + fn on_plan_update(&mut self, update: UpdatePlanArgs) { + self.add_to_history(history_cell::new_plan_update(update)); + } + + fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { + let id2 = id.clone(); + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_exec_approval(id, ev), + |s| s.handle_exec_approval_now(id2, ev2), + ); + } + + fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { + let id2 = id.clone(); + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_apply_patch_approval(id, ev), + |s| s.handle_apply_patch_approval_now(id2, ev2), + ); + } + + fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_elicitation(ev), + |s| s.handle_elicitation_request_now(ev2), + ); + } + + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { + self.flush_answer_stream_with_separator(); + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); + } + + fn on_exec_command_output_delta( + &mut self, + _ev: codex_core::protocol::ExecCommandOutputDeltaEvent, + ) { + // TODO: Handle streaming exec output if/when implemented + } + + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { + self.add_to_history(history_cell::new_patch_event( + event.changes, + &self.config.cwd, + )); + } + + fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_view_image_tool_call( + event.path, + &self.config.cwd, + )); + self.request_redraw(); + } + + fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { + let ev2 = event.clone(); + self.defer_or_handle( + |q| q.push_patch_end(event), + |s| s.handle_patch_apply_end_now(ev2), + ); + } + + fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); + } + + fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); + } + + fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { + let ev2 = ev.clone(); + self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); + } + + fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + self.flush_answer_stream_with_separator(); + } + + fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_web_search_call(format!( + "Searched: {}", + ev.query + ))); + } + + fn on_get_history_entry_response( + &mut self, + event: codex_core::protocol::GetHistoryEntryResponseEvent, + ) { + let codex_core::protocol::GetHistoryEntryResponseEvent { + offset, + log_id, + entry, + } = event; + self.bottom_pane + .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); + } + + fn on_shutdown_complete(&mut self) { + self.request_exit(); + } + + fn on_turn_diff(&mut self, unified_diff: String) { + debug!("TurnDiffEvent: {unified_diff}"); + } + + fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { + let DeprecationNoticeEvent { summary, details } = event; + self.add_to_history(history_cell::new_deprecation_notice(summary, details)); + self.request_redraw(); + } + + fn on_background_event(&mut self, message: String) { + debug!("BackgroundEvent: {message}"); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(true); + self.set_status_header(message); + } + + fn on_undo_started(&mut self, event: UndoStartedEvent) { + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(false); + let message = event + .message + .unwrap_or_else(|| "Undo in progress...".to_string()); + self.set_status_header(message); + } + + fn on_undo_completed(&mut self, event: UndoCompletedEvent) { + let UndoCompletedEvent { success, message } = event; + self.bottom_pane.hide_status_indicator(); + let message = message.unwrap_or_else(|| { + if success { + "Undo completed successfully.".to_string() + } else { + "Undo failed.".to_string() + } + }); + if success { + self.add_info_message(message, None); + } else { + self.add_error_message(message); + } + } + + fn on_stream_error(&mut self, message: String) { + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status_header.clone()); + } + self.set_status_header(message); + } + + /// Periodic tick to commit at most one queued line to history with a small delay, + /// animating the output. + pub(crate) fn on_commit_tick(&mut self) { + if let Some(controller) = self.stream_controller.as_mut() { + let (cell, is_idle) = controller.on_commit_tick(); + if let Some(cell) = cell { + self.bottom_pane.hide_status_indicator(); + self.add_boxed_history(cell); + } + if is_idle { + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + } + } + + fn flush_interrupt_queue(&mut self) { + let mut mgr = std::mem::take(&mut self.interrupts); + mgr.flush_all(self); + self.interrupts = mgr; + } + + #[inline] + fn defer_or_handle( + &mut self, + push: impl FnOnce(&mut InterruptManager), + handle: impl FnOnce(&mut Self), + ) { + // Preserve deterministic FIFO across queued interrupts: once anything + // is queued due to an active write cycle, continue queueing until the + // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). + if self.stream_controller.is_some() || !self.interrupts.is_empty() { + push(&mut self.interrupts); + } else { + handle(self); + } + } + + fn handle_stream_finished(&mut self) { + if self.task_complete_pending { + self.bottom_pane.hide_status_indicator(); + self.task_complete_pending = false; + } + // A completed stream indicates non-exec content was just inserted. + self.flush_interrupt_queue(); + } + + #[inline] + fn handle_streaming_delta(&mut self, delta: String) { + // Before streaming agent content, flush any active exec cell group. + self.flush_active_cell(); + + if self.stream_controller.is_none() { + if self.needs_final_message_separator { + let elapsed_seconds = self + .bottom_pane + .status_widget() + .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); + self.needs_final_message_separator = false; + } + self.stream_controller = Some(StreamController::new( + self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + } + self.request_redraw(); + } + + pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { + let running = self.running_commands.remove(&ev.call_id); + if self.suppressed_exec_calls.remove(&ev.call_id) { + return; + } + let (command, parsed, source) = match running { + Some(rc) => (rc.command, rc.parsed_cmd, rc.source), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), + }; + let is_unified_exec_interaction = + matches!(source, ExecCommandSource::UnifiedExecInteraction); + + let needs_new = self + .active_cell + .as_ref() + .map(|cell| cell.as_any().downcast_ref::().is_none()) + .unwrap_or(true); + if needs_new { + self.flush_active_cell(); + self.active_cell = Some(Box::new(new_active_exec_command( + ev.call_id.clone(), + command, + parsed, + source, + ev.interaction_input.clone(), + self.config.animations, + ))); + } + + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + { + let output = if is_unified_exec_interaction { + CommandOutput { + exit_code: ev.exit_code, + formatted_output: String::new(), + aggregated_output: String::new(), + } + } else { + CommandOutput { + exit_code: ev.exit_code, + formatted_output: ev.formatted_output.clone(), + aggregated_output: ev.aggregated_output.clone(), + } + }; + cell.complete_call(&ev.call_id, output, ev.duration); + if cell.should_flush() { + self.flush_active_cell(); + } + } + } + + pub(crate) fn handle_patch_apply_end_now( + &mut self, + event: codex_core::protocol::PatchApplyEndEvent, + ) { + // If the patch was successful, just let the "Edited" block stand. + // Otherwise, add a failure block. + if !event.success { + self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); + } + } + + pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + let command = shlex::try_join(ev.command.iter().map(String::as_str)) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); + + let request = ApprovalRequest::Exec { + id, + command: ev.command, + reason: ev.reason, + proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + } + + pub(crate) fn handle_apply_patch_approval_now( + &mut self, + id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + self.flush_answer_stream_with_separator(); + + let request = ApprovalRequest::ApplyPatch { + id, + reason: ev.reason, + changes: ev.changes.clone(), + cwd: self.config.cwd.clone(), + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.clone(), + changes: ev.changes.keys().cloned().collect(), + }); + } + + pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { + self.flush_answer_stream_with_separator(); + + self.notify(Notification::ElicitationRequested { + server_name: ev.server_name.clone(), + }); + + let request = ApprovalRequest::McpElicitation { + server_name: ev.server_name, + request_id: ev.id, + message: ev.message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.request_redraw(); + } + + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { + // Ensure the status indicator is visible while the command runs. + self.running_commands.insert( + ev.call_id.clone(), + RunningCommand { + command: ev.command.clone(), + parsed_cmd: ev.parsed_cmd.clone(), + source: ev.source, + }, + ); + let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) + && ev + .interaction_input + .as_deref() + .map(str::is_empty) + .unwrap_or(true); + let command_display = ev.command.join(" "); + let should_suppress_unified_wait = is_wait_interaction + && self + .last_unified_wait + .as_ref() + .is_some_and(|wait| wait.is_duplicate(&command_display)); + if is_wait_interaction { + self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); + } else { + self.last_unified_wait = None; + } + if should_suppress_unified_wait { + self.suppressed_exec_calls.insert(ev.call_id); + return; + } + let interaction_input = ev.interaction_input.clone(); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + && let Some(new_exec) = cell.with_added_call( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd.clone(), + ev.source, + interaction_input.clone(), + ) + { + *cell = new_exec; + } else { + self.flush_active_cell(); + + self.active_cell = Some(Box::new(new_active_exec_command( + ev.call_id.clone(), + ev.command.clone(), + ev.parsed_cmd, + ev.source, + interaction_input, + self.config.animations, + ))); + } + + self.request_redraw(); + } + + pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + ev.call_id, + ev.invocation, + self.config.animations, + ))); + self.request_redraw(); + } + pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { + self.flush_answer_stream_with_separator(); + + let McpToolCallEndEvent { + call_id, + invocation, + duration, + result, + } = ev; + + let extra_cell = match self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), + _ => { + self.flush_active_cell(); + let mut cell = history_cell::new_active_mcp_tool_call( + call_id, + invocation, + self.config.animations, + ); + let extra_cell = cell.complete(duration, result); + self.active_cell = Some(Box::new(cell)); + extra_cell + } + }; + + self.flush_active_cell(); + if let Some(extra) = extra_cell { + self.add_boxed_history(extra); + } + } + + pub(crate) fn new( + common: ChatWidgetInit, + conversation_manager: Arc, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + is_first_run, + model_family, + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + initial_user_message: create_initial_user_message( + initial_prompt.unwrap_or_default(), + initial_images, + ), + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + queued_user_messages: VecDeque::new(), + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + widget + } + + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). + pub(crate) fn new_from_existing( + common: ChatWidgetInit, + conversation: std::sync::Arc, + session_configured: codex_core::protocol::SessionConfiguredEvent, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + model_family, + .. + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + + let codex_op_tx = + spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + initial_user_message: create_initial_user_message( + initial_prompt.unwrap_or_default(), + initial_images, + ), + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + queued_user_messages: VecDeque::new(), + show_welcome_banner: false, + suppress_session_configured_redraw: true, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + widget + } + + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { + self.on_ctrl_c(); + return; + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.eq_ignore_ascii_case(&'v') => + { + match paste_image_to_temp_png() { + Ok((path, info)) => { + self.attach_image( + path, + info.width, + info.height, + info.encoded_format.label(), + ); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}", + ))); + } + } + return; + } + other if other.kind == KeyEventKind::Press => { + self.bottom_pane.clear_ctrl_c_quit_hint(); + } + _ => {} + } + + match key_event { + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + .. + } if !self.queued_user_messages.is_empty() => { + // Prefer the most recently queued item. + if let Some(user_message) = self.queued_user_messages.pop_back() { + self.bottom_pane.set_composer_text(user_message.text); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + } + _ => { + match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted(text) => { + // If a task is running, queue the user input to be sent after the turn completes. + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::None => {} + } + } + } + } + + pub(crate) fn attach_image( + &mut self, + path: PathBuf, + width: u32, + height: u32, + format_label: &str, + ) { + tracing::info!( + "attach_image path={path:?} width={width} height={height} format={format_label}", + ); + self.bottom_pane + .attach_image(path, width, height, format_label); + self.request_redraw(); + } + + fn dispatch_command(&mut self, cmd: SlashCommand) { + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + return; + } + match cmd { + SlashCommand::Feedback => { + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } + SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, None); + return; + } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_user_message(INIT_PROMPT.to_string().into()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + SlashCommand::Review => { + self.open_review_popup(); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Approvals => { + self.open_approvals_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_exit(); + } + SlashCommand::Logout => { + if let Err(e) = codex_core::auth::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + tracing::error!("failed to logout: {e}"); + } + self.request_exit(); + } + SlashCommand::Undo => { + self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Skills => { + self.insert_str("$"); + } + SlashCommand::Status => { + self.add_status_output(); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + SlashCommand::Rollout => { + if let Some(path) = self.rollout_path() { + self.add_info_message( + format!("Current rollout path: {}", path.display()), + None, + ); + } else { + self.add_info_message("Rollout path is not available yet.".to_string(), None); + } + } + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; + + use codex_core::protocol::ApplyPatchApprovalRequestEvent; + use codex_core::protocol::FileChange; + + self.app_event_tx.send(AppEvent::CodexEvent(Event { + id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + // call_id: "1".to_string(), + // command: vec!["git".into(), "apply".into()], + // cwd: self.config.cwd.clone(), + // reason: Some("test".to_string()), + // }), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + turn_id: "turn-1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }), + })); + } + } + } + + pub(crate) fn handle_paste(&mut self, text: String) { + self.bottom_pane.handle_paste(text); + } + + // Returns true if caller should skip rendering this frame (a future frame is scheduled). + pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { + if self.bottom_pane.flush_paste_burst_if_due() { + // A paste just flushed; request an immediate redraw and skip this frame. + self.request_redraw(); + true + } else if self.bottom_pane.is_in_paste_burst() { + // While capturing a burst, schedule a follow-up tick and skip this frame + // to avoid redundant renders between ticks. + frame_requester.schedule_frame_in( + crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), + ); + true + } else { + false + } + } + + fn flush_active_cell(&mut self) { + if let Some(active) = self.active_cell.take() { + self.needs_final_message_separator = true; + self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + } + } + + fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { + self.add_boxed_history(Box::new(cell)); + } + + fn add_boxed_history(&mut self, cell: Box) { + if !cell.display_lines(u16::MAX).is_empty() { + // Only break exec grouping if the cell renders visible lines. + self.flush_active_cell(); + self.needs_final_message_separator = true; + } + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + + fn queue_user_message(&mut self, user_message: UserMessage) { + if self.bottom_pane.is_task_running() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else { + self.submit_user_message(user_message); + } + } + + fn submit_user_message(&mut self, user_message: UserMessage) { + let UserMessage { text, image_paths } = user_message; + if text.is_empty() && image_paths.is_empty() { + return; + } + + let mut items: Vec = Vec::new(); + + // Special-case: "!cmd" executes a local shell command instead of sending to the model. + if let Some(stripped) = text.strip_prefix('!') { + let cmd = stripped.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + return; + } + self.submit_op(Op::RunUserShellCommand { + command: cmd.to_string(), + }); + return; + } + + if !text.is_empty() { + items.push(UserInput::Text { text: text.clone() }); + } + + for path in image_paths { + items.push(UserInput::LocalImage { path }); + } + + self.codex_op_tx + .send(Op::UserInput { items }) + .unwrap_or_else(|e| { + tracing::error!("failed to send message: {e}"); + }); + + // Persist the text to cross-session message history. + if !text.is_empty() { + self.codex_op_tx + .send(Op::AddToHistory { text: text.clone() }) + .unwrap_or_else(|e| { + tracing::error!("failed to send AddHistory op: {e}"); + }); + } + + // Only show the text portion in conversation history. + if !text.is_empty() { + self.add_to_history(history_cell::new_user_prompt(text)); + } + self.needs_final_message_separator = false; + } + + /// Replay a subset of initial events into the UI to seed the transcript when + /// resuming an existing session. This approximates the live event flow and + /// is intentionally conservative: only safe-to-replay items are rendered to + /// avoid triggering side effects. Event ids are passed as `None` to + /// distinguish replayed events from live ones. + fn replay_initial_messages(&mut self, events: Vec) { + for msg in events { + if matches!(msg, EventMsg::SessionConfigured(_)) { + continue; + } + // `id: None` indicates a synthetic/fake id coming from replay. + self.dispatch_event_msg(None, msg, true); + } + } + + pub(crate) fn handle_codex_event(&mut self, event: Event) { + let Event { id, msg } = event; + self.dispatch_event_msg(Some(id), msg, false); + } + + /// Dispatch a protocol `EventMsg` to the appropriate handler. + /// + /// `id` is `Some` for live events and `None` for replayed events from + /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id + /// that must not be used to correlate follow-up actions. + fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { + let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); + if !is_stream_error { + self.restore_retry_status_header_if_present(); + } + + match msg { + EventMsg::AgentMessageDelta(_) + | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) + | EventMsg::ExecCommandOutputDelta(_) => {} + _ => { + tracing::trace!("handle_codex_event: {:?}", msg); + } + } + + match msg { + EventMsg::SessionConfigured(e) => self.on_session_configured(e), + EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + self.on_agent_message_delta(delta) + } + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) + | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { + delta, + }) => self.on_agent_reasoning_delta(delta), + EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { + self.on_agent_reasoning_delta(text); + self.on_agent_reasoning_final(); + } + EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), + EventMsg::TaskStarted(_) => self.on_task_started(), + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + self.on_task_complete(last_agent_message) + } + EventMsg::TokenCount(ev) => { + self.set_token_info(ev.info); + self.on_rate_limit_snapshot(ev.rate_limits); + } + EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), + EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), + EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), + EventMsg::TurnAborted(ev) => match ev.reason { + TurnAbortReason::Interrupted => { + self.on_interrupted_turn(ev.reason); + } + TurnAbortReason::Replaced => { + self.on_error("Turn aborted: replaced by a new task".to_owned()) + } + TurnAbortReason::ReviewEnded => { + self.on_interrupted_turn(ev.reason); + } + }, + EventMsg::PlanUpdate(update) => self.on_plan_update(update), + EventMsg::ExecApprovalRequest(ev) => { + // For replayed events, synthesize an empty id (these should not occur). + self.on_exec_approval_request(id.unwrap_or_default(), ev) + } + EventMsg::ApplyPatchApprovalRequest(ev) => { + self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) + } + EventMsg::ElicitationRequest(ev) => { + self.on_elicitation_request(ev); + } + EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), + EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), + EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), + EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), + EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), + EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), + EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), + EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), + EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), + EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), + EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), + EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), + EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ShutdownComplete => self.on_shutdown_complete(), + EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), + EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), + EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + self.on_background_event(message) + } + EventMsg::UndoStarted(ev) => self.on_undo_started(ev), + EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), + EventMsg::StreamError(StreamErrorEvent { message, .. }) => { + self.on_stream_error(message) + } + EventMsg::UserMessage(ev) => { + if from_replay { + self.on_user_message_event(ev); + } + } + EventMsg::EnteredReviewMode(review_request) => { + self.on_entered_review_mode(review_request) + } + EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), + EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), + EventMsg::RawResponseItem(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) => {} + } + } + + fn on_entered_review_mode(&mut self, review: ReviewRequest) { + // Enter review mode and emit a concise banner + if self.pre_review_token_info.is_none() { + self.pre_review_token_info = Some(self.token_info.clone()); + } + self.is_review_mode = true; + let hint = review + .user_facing_hint + .unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target)); + let banner = format!(">> Code review started: {hint} <<"); + self.add_to_history(history_cell::new_review_status_line(banner)); + self.request_redraw(); + } + + fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { + // Leave review mode; if output is present, flush pending stream + show results. + if let Some(output) = review.review_output { + self.flush_answer_stream_with_separator(); + self.flush_interrupt_queue(); + self.flush_active_cell(); + + if output.findings.is_empty() { + let explanation = output.overall_explanation.trim().to_string(); + if explanation.is_empty() { + tracing::error!("Reviewer failed to output a response."); + self.add_to_history(history_cell::new_error_event( + "Reviewer failed to output a response.".to_owned(), + )); + } else { + // Show explanation when there are no structured findings. + let mut rendered: Vec> = vec!["".into()]; + append_markdown(&explanation, None, &mut rendered); + let body_cell = AgentMessageCell::new(rendered, false); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } else { + let message_text = + codex_core::review_format::format_review_findings_block(&output.findings, None); + let mut message_lines: Vec> = Vec::new(); + append_markdown(&message_text, None, &mut message_lines); + let body_cell = AgentMessageCell::new(message_lines, true); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } + + self.is_review_mode = false; + self.restore_pre_review_token_info(); + // Append a finishing banner at the end of this turn. + self.add_to_history(history_cell::new_review_status_line( + "<< Code review finished >>".to_string(), + )); + self.request_redraw(); + } + + fn on_user_message_event(&mut self, event: UserMessageEvent) { + let message = event.message.trim(); + if !message.is_empty() { + self.add_to_history(history_cell::new_user_prompt(message.to_string())); + } + } + + fn request_exit(&self) { + self.app_event_tx.send(AppEvent::ExitRequest); + } + + fn request_redraw(&mut self) { + self.frame_requester.schedule_frame(); + } + + fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications) { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { + if let Some(notif) = self.pending_notification.take() { + tui.notify(notif.display()); + } + } + + /// Mark the active cell as failed (✗) and flush it into history. + fn finalize_active_cell_as_failed(&mut self) { + if let Some(mut cell) = self.active_cell.take() { + // Insert finalized cell into history and keep grouping consistent. + if let Some(exec) = cell.as_any_mut().downcast_mut::() { + exec.mark_failed(); + } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { + tool.mark_failed(); + } + self.add_boxed_history(cell); + } + } + + // If idle and there are queued inputs, submit exactly one to start the next turn. + fn maybe_send_next_queued_input(&mut self) { + if self.bottom_pane.is_task_running() { + return; + } + if let Some(user_message) = self.queued_user_messages.pop_front() { + self.submit_user_message(user_message); + } + // Update the list to reflect the remaining queued messages (if any). + self.refresh_queued_user_messages(); + } + + /// Rebuild and update the queued user messages from the current queue. + fn refresh_queued_user_messages(&mut self) { + let messages: Vec = self + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect(); + self.bottom_pane.set_queued_user_messages(messages); + } + + pub(crate) fn add_diff_in_progress(&mut self) { + self.request_redraw(); + } + + pub(crate) fn on_diff_complete(&mut self) { + self.request_redraw(); + } + + pub(crate) fn add_status_output(&mut self) { + let default_usage = TokenUsage::default(); + let (total_usage, context_usage) = if let Some(ti) = &self.token_info { + (&ti.total_token_usage, Some(&ti.last_token_usage)) + } else { + (&default_usage, Some(&default_usage)) + }; + self.add_to_history(crate::status::new_status_output( + &self.config, + self.auth_manager.as_ref(), + &self.model_family, + total_usage, + context_usage, + &self.conversation_id, + self.rate_limit_snapshot.as_ref(), + self.plan_type, + Local::now(), + self.model_family.get_model_slug(), + )); + } + fn stop_rate_limit_poller(&mut self) { + if let Some(handle) = self.rate_limit_poller.take() { + handle.abort(); + } + } + + fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + + let Some(auth) = self.auth_manager.auth() else { + return; + }; + if auth.mode != AuthMode::ChatGPT { + return; + } + + let base_url = self.config.chatgpt_base_url.clone(); + let app_event_tx = self.app_event_tx.clone(); + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + + loop { + if let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth.clone()).await { + app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); + } + interval.tick().await; + } + }); + + self.rate_limit_poller = Some(handle); + } + + fn lower_cost_preset(&self) -> Option { + let models = self.models_manager.try_list_models().ok()?; + models + .iter() + .find(|preset| preset.model == NUDGE_MODEL_SLUG) + .cloned() + } + + fn rate_limit_switch_prompt_hidden(&self) -> bool { + self.config + .notices + .hide_rate_limit_model_nudge + .unwrap_or(false) + } + + fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.rate_limit_switch_prompt_hidden() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + return; + } + if !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + if let Some(preset) = self.lower_cost_preset() { + self.open_rate_limit_switch_prompt(preset); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; + } else { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { + let switch_model = preset.model.to_string(); + let display_name = preset.display_name.to_string(); + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + + let switch_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(switch_model.clone()), + effort: Some(Some(default_effort)), + summary: None, + })); + tx.send(AppEvent::UpdateModel(switch_model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + })]; + + let keep_actions: Vec = Vec::new(); + let never_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); + tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); + })]; + let description = if preset.description.is_empty() { + Some("Uses fewer credits for upcoming turns.".to_string()) + } else { + Some(preset.description) + }; + + let items = vec![ + SelectionItem { + name: format!("Switch to {display_name}"), + description, + selected_description: None, + is_current: false, + actions: switch_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model".to_string(), + description: None, + selected_description: None, + is_current: false, + actions: keep_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model (never show again)".to_string(), + description: Some( + "Hide future rate limit reminders about switching models.".to_string(), + ), + selected_description: None, + is_current: false, + actions: never_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Approaching rate limits".to_string()), + subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. + pub(crate) fn open_model_popup(&mut self) { + let current_model = self.model_family.get_model_slug().to_string(); + let presets: Vec = + // todo(aibrahim): make this async function + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } + }; + + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + ); + SelectionItem { + name: preset.display_name, + description, + is_current: model == current_model, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model".to_string()), + subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + None, + ); + return; + } + + let current_model = self.model_family.get_model_slug().to_string(); + let mut items: Vec = Vec::new(); + for preset in presets.into_iter() { + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); + let is_current = preset.model == current_model; + let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; + let preset_for_action = preset.clone(); + let actions: Vec = vec![Box::new(move |tx| { + let preset_for_event = preset_for_action.clone(); + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_event, + }); + })]; + items.push(SelectionItem { + name: preset.display_name.to_string(), + description, + is_current, + actions, + dismiss_on_select: single_supported_effort, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Model and Effort".to_string()), + subtitle: Some( + "Access legacy models by running codex -m or in your config.toml" + .to_string(), + ), + footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), + items, + ..Default::default() + }); + } + + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + ) -> Vec { + vec![Box::new(move |tx| { + let effort_label = effort_for_action + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model_for_action.clone()), + effort: Some(effort_for_action), + summary: None, + })); + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model_for_action, + effort_label + ); + })] + } + + /// Open a popup to choose the reasoning effort (stage 2) for the given model. + pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let supported = preset.supported_reasoning_efforts; + + let warn_effort = if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::XHigh) + { + Some(ReasoningEffortConfig::XHigh) + } else if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::High) + { + Some(ReasoningEffortConfig::High) + } else { + None + }; + let warning_text = warn_effort.map(|effort| { + let effort_label = Self::reasoning_effort_label(effort); + format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") + }); + let warn_for_model = preset.model.starts_with("gpt-5.1-codex") + || preset.model.starts_with("gpt-5.1-codex-max"); + + struct EffortChoice { + stored: Option, + display: ReasoningEffortConfig, + } + let mut choices: Vec = Vec::new(); + for effort in ReasoningEffortConfig::iter() { + if supported.iter().any(|option| option.effort == effort) { + choices.push(EffortChoice { + stored: Some(effort), + display: effort, + }); + } + } + if choices.is_empty() { + choices.push(EffortChoice { + stored: Some(default_effort), + display: default_effort, + }); + } + + if choices.len() == 1 { + if let Some(effort) = choices.first().and_then(|c| c.stored) { + self.apply_model_and_effort(preset.model, Some(effort)); + } else { + self.apply_model_and_effort(preset.model, None); + } + return; + } + + let default_choice: Option = choices + .iter() + .any(|choice| choice.stored == Some(default_effort)) + .then_some(Some(default_effort)) + .flatten() + .or_else(|| choices.iter().find_map(|choice| choice.stored)) + .or(Some(default_effort)); + + let model_slug = preset.model.to_string(); + let is_current_model = self.model_family.get_model_slug() == preset.model; + let highlight_choice = if is_current_model { + self.config.model_reasoning_effort + } else { + default_choice + }; + let selection_choice = highlight_choice.or(default_choice); + let initial_selected_idx = choices + .iter() + .position(|choice| choice.stored == selection_choice) + .or_else(|| { + selection_choice + .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) + }); + let mut items: Vec = Vec::new(); + for choice in choices.iter() { + let effort = choice.display; + let mut effort_label = Self::reasoning_effort_label(effort).to_string(); + if choice.stored == default_choice { + effort_label.push_str(" (default)"); + } + + let description = choice + .stored + .and_then(|effort| { + supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) + }) + .filter(|text| !text.is_empty()); + + let show_warning = warn_for_model && warn_effort == Some(effort); + let selected_description = if show_warning { + warning_text.as_ref().map(|warning_message| { + description.as_ref().map_or_else( + || warning_message.clone(), + |d| format!("{d}\n{warning_message}"), + ) + }) + } else { + None + }; + + let model_for_action = model_slug.clone(); + let actions = Self::model_selection_actions(model_for_action, choice.stored); + + items.push(SelectionItem { + name: effort_label, + description, + selected_description, + is_current: is_current_model && choice.stored == highlight_choice, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + match effort { + ReasoningEffortConfig::None => "None", + ReasoningEffortConfig::Minimal => "Minimal", + ReasoningEffortConfig::Low => "Low", + ReasoningEffortConfig::Medium => "Medium", + ReasoningEffortConfig::High => "High", + ReasoningEffortConfig::XHigh => "Extra high", + } + } + + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.clone()), + effort: Some(effort), + summary: None, + })); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model, + effort + .map(|e| e.to_string()) + .unwrap_or_else(|| "default".to_string()) + ); + } + + /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). + pub(crate) fn open_approvals_popup(&mut self) { + let current_approval = self.config.approval_policy; + let current_sandbox = self.config.sandbox_policy.clone(); + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + for preset in presets.into_iter() { + let is_current = + Self::preset_matches_current(current_approval, ¤t_sandbox, &preset); + let name = preset.label.to_string(); + let description_text = preset.description; + let description = Some(description_text.to_string()); + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + }); + })] + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if codex_core::get_platform_sandbox().is_none() { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }; + items.push(SelectionItem { + name, + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(()), + ..Default::default() + }); + } + + fn approval_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + ) -> Vec { + vec![Box::new(move |tx| { + let sandbox_clone = sandbox.clone(); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox_clone.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + })] + } + + fn preset_matches_current( + current_approval: AskForApproval, + current_sandbox: &SandboxPolicy, + preset: &ApprovalPreset, + ) -> bool { + if current_approval != preset.approval { + return false; + } + matches!( + (&preset.sandbox, current_sandbox), + (SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly) + | ( + SandboxPolicy::DangerFullAccess, + SandboxPolicy::DangerFullAccess + ) + | ( + SandboxPolicy::WorkspaceWrite { .. }, + SandboxPolicy::WorkspaceWrite { .. } + ) + ) + } + + #[cfg(target_os = "windows")] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + if self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + { + return None; + } + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + match codex_windows_sandbox::apply_world_writable_scan_and_denies( + self.config.codex_home.as_path(), + cwd.as_path(), + &env_map, + &self.config.sandbox_policy, + Some(self.config.codex_home.as_path()), + ) { + Ok(_) => None, + Err(_) => Some((Vec::new(), 0, true)), + } + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + None + } + + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { + let approval = preset.approval; + let sandbox = preset.sandbox; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + preset: Option, + sample_paths: Vec, + extra_count: usize, + failed_scan: bool, + ) { + let (approval, sandbox) = match &preset { + Some(p) => (Some(p.approval), Some(p.sandbox.clone())), + None => (None, None), + }; + let mut header_children: Vec> = Vec::new(); + let describe_policy = |policy: &SandboxPolicy| match policy { + SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", + SandboxPolicy::ReadOnly => "Read-Only mode", + _ => "Agent mode", + }; + let mode_label = preset + .as_ref() + .map(|p| describe_policy(&p.sandbox)) + .unwrap_or_else(|| describe_policy(&self.config.sandbox_policy)); + let info_line = if failed_scan { + Line::from(vec![ + "We couldn't complete the world-writable scan, so protections cannot be verified. " + .into(), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.") + .fg(Color::Red), + ]) + } else { + Line::from(vec![ + "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), + " Consider removing write access for Everyone from the following folders:".into(), + ]) + }; + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + + if !sample_paths.is_empty() { + // Show up to three examples and optionally an "and X more" line. + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + for p in &sample_paths { + lines.push(Line::from(format!(" - {p}"))); + } + if extra_count > 0 { + lines.push(Line::from(format!("and {extra_count} more"))); + } + header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + } + let header = ColumnRenderable::with(header_children); + + // Build actions ensuring acknowledgement happens before applying the new sandbox policy, + // so downstream policy-change hooks don't re-trigger the warning. + let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), + // to avoid duplicate warnings from the ensuing policy change. + if preset.is_some() { + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + } + if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { + accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let mut accept_and_remember_actions: Vec = Vec::new(); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })); + if let (Some(approval), Some(sandbox)) = (approval, sandbox) { + accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let items = vec![ + SelectionItem { + name: "Continue".to_string(), + description: Some(format!("Apply {mode_label} for this session")), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Continue and don't warn again".to_string(), + description: Some(format!("Enable {mode_label} and remember this choice")), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + _preset: Option, + _sample_paths: Vec, + _extra_count: usize, + _failed_scan: bool, + ) { + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], + line![ + "Learn more: https://developers.openai.com/codex/windows" + ], + ]) + .wrap(Wrap { trim: false }), + )); + + let preset_clone = preset; + let items = vec![ + SelectionItem { + name: "Enable experimental sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Go back".to_string(), + description: None, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { + if self.config.forced_auto_mode_downgraded_on_windows + && codex_core::get_platform_sandbox().is_none() + && let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + { + self.open_windows_sandbox_enable_prompt(preset); + } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} + + /// Set the approval policy in the widget's config copy. + pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { + self.config.approval_policy = policy; + } + + /// Set the sandbox policy in the widget's config copy. + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + #[cfg(target_os = "windows")] + let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) + || codex_core::get_platform_sandbox().is_some(); + + self.config.sandbox_policy = policy; + + #[cfg(target_os = "windows")] + if should_clear_downgrade { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + } + + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + + pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_world_writable_warning = Some(acknowledged); + } + + pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { + self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + if hidden { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn world_writable_warning_hidden(&self) -> bool { + self.config + .notices + .hide_world_writable_warning + .unwrap_or(false) + } + + /// Set the reasoning effort in the widget's config copy. + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.config.model_reasoning_effort = effort; + } + + /// Set the model in the widget's config copy. + pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { + self.session_header.set_model(model); + self.model_family = model_family; + } + + pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { + self.add_to_history(history_cell::new_info_event(message, hint)); + self.request_redraw(); + } + + pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { + self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); + self.request_redraw(); + } + + pub(crate) fn add_error_message(&mut self, message: String) { + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + } + + pub(crate) fn add_mcp_output(&mut self) { + if self.config.mcp_servers.is_empty() { + self.add_to_history(history_cell::empty_mcp_output()); + } else { + self.submit_op(Op::ListMcpTools); + } + } + + /// Forward file-search results to the bottom pane. + pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { + self.bottom_pane.on_file_search_result(query, matches); + } + + /// Handle Ctrl-C key press. + fn on_ctrl_c(&mut self) { + if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { + return; + } + + if self.bottom_pane.is_task_running() { + self.bottom_pane.show_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + return; + } + + self.submit_op(Op::Shutdown); + } + + pub(crate) fn composer_is_empty(&self) -> bool { + self.bottom_pane.composer_is_empty() + } + + /// True when the UI is in the regular composer state with no running task, + /// no modal overlay (e.g. approvals or status indicator), and no composer popups. + /// In this state Esc-Esc backtracking is enabled. + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { + self.bottom_pane.is_normal_backtrack_mode() + } + + pub(crate) fn insert_str(&mut self, text: &str) { + self.bottom_pane.insert_str(text); + } + + /// Replace the composer content with the provided text and reset cursor. + pub(crate) fn set_composer_text(&mut self, text: String) { + self.bottom_pane.set_composer_text(text); + } + + pub(crate) fn show_esc_backtrack_hint(&mut self) { + self.bottom_pane.show_esc_backtrack_hint(); + } + + pub(crate) fn clear_esc_backtrack_hint(&mut self) { + self.bottom_pane.clear_esc_backtrack_hint(); + } + /// Forward an `Op` directly to codex. + pub(crate) fn submit_op(&self, op: Op) { + // Record outbound operation for session replay fidelity. + crate::session_log::log_outbound_op(&op); + if let Err(e) = self.codex_op_tx.send(op) { + tracing::error!("failed to submit op: {e}"); + } + } + + fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); + } + + fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { + let len = ev.custom_prompts.len(); + debug!("received {len} custom prompts"); + // Forward to bottom pane so the slash popup can show them now. + self.bottom_pane.set_custom_prompts(ev.custom_prompts); + } + + pub(crate) fn open_review_popup(&mut self) { + let mut items: Vec = Vec::new(); + + items.push(SelectionItem { + name: "Review against a base branch".to_string(), + description: Some("(PR Style)".into()), + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Review uncommitted changes".to_string(), + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + ..Default::default() + }); + + // New: Review a specific commit (opens commit picker) + items.push(SelectionItem { + name: "Review a commit".to_string(), + actions: vec![Box::new({ + let cwd = self.config.cwd.clone(); + move |tx| { + tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Custom review instructions".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReviewCustomPrompt); + })], + dismiss_on_select: false, + ..Default::default() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a review preset".into()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { + let branches = local_git_branches(cwd).await; + let current_branch = current_branch_name(cwd) + .await + .unwrap_or_else(|| "(detached HEAD)".to_string()); + let mut items: Vec = Vec::with_capacity(branches.len()); + + for option in branches { + let branch = option.clone(); + items.push(SelectionItem { + name: format!("{current_branch} -> {branch}"), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: branch.clone(), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(option), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a base branch".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }); + } + + pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { + let commits = codex_core::git_info::recent_commits(cwd, 100).await; + + let mut items: Vec = Vec::with_capacity(commits.len()); + for entry in commits { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..Default::default() + }); + } + + pub(crate) fn show_review_custom_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Custom review instructions".to_string(), + "Type instructions and press Enter".to_string(), + None, + Box::new(move |prompt: String| { + let trimmed = prompt.trim().to_string(); + if trimmed.is_empty() { + return; + } + tx.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Custom { + instructions: trimmed, + }, + user_facing_hint: None, + }, + })); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn token_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|ti| ti.total_token_usage.clone()) + .unwrap_or_default() + } + + pub(crate) fn conversation_id(&self) -> Option { + self.conversation_id + } + + pub(crate) fn rollout_path(&self) -> Option { + self.current_rollout_path.clone() + } + + /// Return a reference to the widget's current config (includes any + /// runtime overrides applied via TUI, e.g., model or approval policy). + pub(crate) fn config_ref(&self) -> &Config { + &self.config + } + + pub(crate) fn clear_token_usage(&mut self) { + self.token_info = None; + } + + fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_renderable = match &self.active_cell { + Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), + None => RenderableItem::Owned(Box::new(())), + }; + let mut flex = FlexRenderable::new(); + flex.push(1, active_cell_renderable); + flex.push( + 0, + RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), + ); + RenderableItem::Owned(Box::new(flex)) + } +} + +impl Drop for ChatWidget { + fn drop(&mut self) { + self.stop_rate_limit_poller(); + } +} + +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + self.last_rendered_width.set(Some(area.width as usize)); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } +} + +enum Notification { + AgentTurnComplete { response: String }, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, + ElicitationRequested { server_name: String }, +} + +impl Notification { + fn display(&self) -> String { + match self { + Notification::AgentTurnComplete { response } => { + Notification::agent_turn_preview(response) + .unwrap_or_else(|| "Agent turn complete".to_string()) + } + Notification::ExecApprovalRequested { command } => { + format!("Approval requested: {}", truncate_text(command, 30)) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + Notification::ElicitationRequested { server_name } => { + format!("Approval requested by {server_name}") + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete { .. } => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } + | Notification::ElicitationRequested { .. } => "approval-requested", + } + } + + fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), + } + } + + fn agent_turn_preview(response: &str) -> Option { + let mut normalized = String::new(); + for part in response.split_whitespace() { + if !normalized.is_empty() { + normalized.push(' '); + } + normalized.push_str(part); + } + let trimmed = normalized.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) + } + } +} + +const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; + +const EXAMPLE_PROMPTS: [&str; 6] = [ + "Explain this codebase", + "Summarize recent commits", + "Implement {feature}", + "Find and fix a bug in @filename", + "Write tests for @filename", + "Improve documentation in @filename", +]; + +// Extract the first bold (Markdown) element in the form **...** from `s`. +// Returns the inner text if found; otherwise `None`. +fn extract_first_bold(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0usize; + while i + 1 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' { + let start = i + 2; + let mut j = start; + while j + 1 < bytes.len() { + if bytes[j] == b'*' && bytes[j + 1] == b'*' { + // Found closing ** + let inner = &s[start..j]; + let trimmed = inner.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } else { + return None; + } + } + j += 1; + } + // No closing; stop searching (wait for more deltas) + return None; + } + i += 1; + } + None +} + +async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { + match BackendClient::from_auth(base_url, &auth).await { + Ok(client) => match client.get_rate_limits().await { + Ok(snapshot) => Some(snapshot), + Err(err) => { + debug!(error = ?err, "failed to fetch rate limits from /usage"); + None + } + }, + Err(err) => { + debug!(error = ?err, "failed to construct backend client for rate limits"); + None + } + } +} + +#[cfg(test)] +pub(crate) fn show_review_commit_picker_with_entries( + chat: &mut ChatWidget, + entries: Vec, +) { + let mut items: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.send(AppEvent::CodexOp(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }, + })); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + chat.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..Default::default() + }); +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs new file mode 100644 index 0000000000..240972347f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/agent.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::NewConversation; +use codex_core::config::Config; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::unbounded_channel; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +/// Spawn the agent bootstrapper and op forwarding loop, returning the +/// `UnboundedSender` used by the UI to submit operations. +pub(crate) fn spawn_agent( + config: Config, + app_event_tx: AppEventSender, + server: Arc, +) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + let app_event_tx_clone = app_event_tx; + tokio::spawn(async move { + let NewConversation { + conversation_id: _, + conversation, + session_configured, + } = match server.new_conversation(config).await { + Ok(v) => v, + #[allow(clippy::print_stderr)] + Err(err) => { + let message = err.to_string(); + eprintln!("{message}"); + app_event_tx_clone.send(AppEvent::CodexEvent(Event { + id: "".to_string(), + msg: EventMsg::Error(err.to_error_event(None)), + })); + app_event_tx_clone.send(AppEvent::ExitRequest); + tracing::error!("failed to initialize codex: {err}"); + return; + } + }; + + // Forward the captured `SessionConfigured` event so it can be rendered in the UI. + let ev = codex_core::protocol::Event { + // The `id` does not matter for rendering, so we can use a fake value. + id: "".to_string(), + msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + }; + app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + + let conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.next_event().await { + app_event_tx_clone.send(AppEvent::CodexEvent(event)); + } + }); + + codex_op_tx +} + +/// Spawn agent loops for an existing conversation (e.g., a forked conversation). +/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent +/// events and accepts Ops for submission. +pub(crate) fn spawn_agent_from_existing( + conversation: std::sync::Arc, + session_configured: codex_core::protocol::SessionConfiguredEvent, + app_event_tx: AppEventSender, +) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + let app_event_tx_clone = app_event_tx; + tokio::spawn(async move { + // Forward the captured `SessionConfigured` event so it can be rendered in the UI. + let ev = codex_core::protocol::Event { + id: "".to_string(), + msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), + }; + app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + + let conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.next_event().await { + app_event_tx_clone.send(AppEvent::CodexEvent(event)); + } + }); + + codex_op_tx +} diff --git a/codex-rs/tui2/src/chatwidget/interrupts.rs b/codex-rs/tui2/src/chatwidget/interrupts.rs new file mode 100644 index 0000000000..dc1e683ea5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/interrupts.rs @@ -0,0 +1,96 @@ +use std::collections::VecDeque; + +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::McpToolCallBeginEvent; +use codex_core::protocol::McpToolCallEndEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_protocol::approvals::ElicitationRequestEvent; + +use super::ChatWidget; + +#[derive(Debug)] +pub(crate) enum QueuedInterrupt { + ExecApproval(String, ExecApprovalRequestEvent), + ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), + Elicitation(ElicitationRequestEvent), + ExecBegin(ExecCommandBeginEvent), + ExecEnd(ExecCommandEndEvent), + McpBegin(McpToolCallBeginEvent), + McpEnd(McpToolCallEndEvent), + PatchEnd(PatchApplyEndEvent), +} + +#[derive(Default)] +pub(crate) struct InterruptManager { + queue: VecDeque, +} + +impl InterruptManager { + pub(crate) fn new() -> Self { + Self { + queue: VecDeque::new(), + } + } + + #[inline] + pub(crate) fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { + self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev)); + } + + pub(crate) fn push_apply_patch_approval( + &mut self, + id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + self.queue + .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); + } + + pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { + self.queue.push_back(QueuedInterrupt::Elicitation(ev)); + } + + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { + self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); + } + + pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { + self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); + } + + pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { + self.queue.push_back(QueuedInterrupt::McpBegin(ev)); + } + + pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { + self.queue.push_back(QueuedInterrupt::McpEnd(ev)); + } + + pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { + self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); + } + + pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { + while let Some(q) = self.queue.pop_front() { + match q { + QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), + QueuedInterrupt::ApplyPatchApproval(id, ev) => { + chat.handle_apply_patch_approval_now(id, ev) + } + QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), + QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), + QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), + QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), + QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), + } + } + } +} diff --git a/codex-rs/tui2/src/chatwidget/session_header.rs b/codex-rs/tui2/src/chatwidget/session_header.rs new file mode 100644 index 0000000000..32e31b6682 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/session_header.rs @@ -0,0 +1,16 @@ +pub(crate) struct SessionHeader { + model: String, +} + +impl SessionHeader { + pub(crate) fn new(model: String) -> Self { + Self { model } + } + + /// Updates the header's model text. + pub(crate) fn set_model(&mut self, model: &str) { + if self.model != model { + self.model = model.to_string(); + } + } +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap new file mode 100644 index 0000000000..26c7f58709 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&approved_lines) +--- +• Added foo.txt (+1 -0) + 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap new file mode 100644 index 0000000000..c69730b483 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + Reason: this is a test reason such as one that would be produced by the model + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 0000000000..ab469f34b6 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap new file mode 100644 index 0000000000..a5bfd136b7 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to make the following edits? + + Reason: The model wants to apply changes + + README.md (+2 -0) + + 1 +hello + 2 +world + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 0000000000..46ec74d117 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap new file mode 100644 index 0000000000..5024b90a62 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back + diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap new file mode 100644 index 0000000000..8900e83d9a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap new file mode 100644 index 0000000000..a2afe14dfa --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap new file mode 100644 index 0000000000..1b285fb810 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap new file mode 100644 index 0000000000..8900e83d9a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap new file mode 100644 index 0000000000..a2afe14dfa --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap new file mode 100644 index 0000000000..1b285fb810 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap new file mode 100644 index 0000000000..a447b748bb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + +• Investigating rendering code (0s • esc to interrupt) + + +› Summarize recent commits + + 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap new file mode 100644 index 0000000000..9ab9b03380 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + + ```sh + printf 'fenced within fenced\n' + ``` + + { + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" + } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 0000000000..3cc0b593d4 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap new file mode 100644 index 0000000000..3d83bdb0f5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap new file mode 100644 index 0000000000..6d252a0d3e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: blob +--- +■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap new file mode 100644 index 0000000000..50c0828773 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_long) +--- +✗ You canceled the request to run echo + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap new file mode 100644 index 0000000000..d7e1e2ac3a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_multi) +--- +✗ You canceled the request to run echo line1 ... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap new file mode 100644 index 0000000000..2d3767dffb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: lines_to_single_string(&decision) +--- +✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap new file mode 100644 index 0000000000..93451be714 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 13 }, + content: [ + " ", + " ", + " Would you like to run the following command? ", + " ", + " Reason: this is a test reason such as one that would be produced by the ", + " model ", + " ", + " $ echo hello world ", + " ", + "› 1. Yes, proceed (y) ", + " 2. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap new file mode 100644 index 0000000000..7a20304601 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Exploring + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap new file mode 100644 index 0000000000..b13ce510e0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap new file mode 100644 index 0000000000..ab15a80ff3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Exploring + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap new file mode 100644 index 0000000000..21b41860fc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap new file mode 100644 index 0000000000..21b41860fc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap new file mode 100644 index 0000000000..a38d4c7fd2 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: active_blob(&chat) +--- +• Explored + └ List ls -la + Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap new file mode 100644 index 0000000000..52ce03bbea --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + How was this? + +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap new file mode 100644 index 0000000000..21d031df6c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap new file mode 100644 index 0000000000..3d83bdb0f5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 0000000000..f7a2b3dcb6 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap new file mode 100644 index 0000000000..3863f9a8d5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: exec_blob +--- +• Ran sleep 1 + └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 0000000000..943fe34440 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: last +--- +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap new file mode 100644 index 0000000000..31c5e74b0a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: combined +--- +• Viewed Image + └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap new file mode 100644 index 0000000000..cbf5f0fb52 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday tasks +› 3. High (current) Maximizes reasoning depth for complex problems + 4. Extra high Extra high reasoning depth for complex problems + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap new file mode 100644 index 0000000000..ed6c6fee19 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday + tasks + 3. High Maximizes reasoning depth for complex problems +› 4. Extra high (current) Extra high reasoning depth for complex problems + ⚠ Extra high reasoning effort can quickly consume + Plus plan rate limits. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap new file mode 100644 index 0000000000..3937194a1f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap new file mode 100644 index 0000000000..d553957350 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: popup +--- + Approaching rate limits + Switch to gpt-5.1-codex-mini for lower credit usage? + +› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, + faster, but less capable. + 2. Keep current model + 3. Keep current model (never show again) Hide future rate limit reminders + about switching models. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap new file mode 100644 index 0000000000..f761e5730b --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +"• Analyzing (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap new file mode 100644 index 0000000000..567794cea6 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap new file mode 100644 index 0000000000..3a9f08ab94 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +source: tui2/src/chatwidget/tests.rs +expression: blob +--- +• You ran ls + └ file1 + file2 diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap new file mode 100644 index 0000000000..e139b51088 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&approved_lines) +--- +• Added foo.txt (+1 -0) + 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap new file mode 100644 index 0000000000..15511611a1 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + + + Would you like to run the following command? + + Reason: this is a test reason such as one that would be produced by the model + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 0000000000..2bbe9aefcd --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to run the following command? + + $ echo hello world + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap new file mode 100644 index 0000000000..ed18675ac3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + Would you like to make the following edits? + + Reason: The model wants to apply changes + + README.md (+2 -0) + + 1 +hello + 2 +world + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 0000000000..6758ec62c5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap new file mode 100644 index 0000000000..6758ec62c5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap new file mode 100644 index 0000000000..77738439a1 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -0,0 +1,153 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "lines[start_idx..].join(\"\\n\")" +--- +• I need to check the codex-rs repository to explain why the project's binaries + are large. The user is likely seeking specifics about the setup: are Rust + builds static, what features are enabled, and is debug information included? + It could be due to static linking, included OpenSSL, or how panic handling + is set up. I should look into the Cargo.toml file to confirm features and + profiles without needing to edit any code. Let's get started on this! + +─ Worked for 0s ──────────────────────────────────────────────────────────────── + +• I’m going to scan the workspace and Cargo manifests to see build profiles and + dependencies that impact binary size. Then I’ll summarize the main causes. + +• Explored + └ List ls -la + Read Cargo.toml + +• I'm reviewing the workspace's release profile, which has settings like + lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it + seems that "strip = symbols" only removes symbols, not debug information. In + Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I + should check whether using these results in a smaller binary. I’ll also skim + through crates for heavy dependencies in Cargo.toml, including cli, core, + exec, linux-sandbox, tui, login, ollama, and mcp. + +• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy + │ file-search linux-sandbox login mcp-client mcp-server mcp-types ollama + │ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; + │ … +1 lines + └ --- ansi-escape/Cargo.toml + [package] + … +243 lines + ] } + tracing = { version + +• Explored + └ Read Cargo.toml + +• I’m evaluating how binary size is influenced by various factors. There are + many binaries, such as codex and codex-exec, which include dependencies by + default, pulling in features like Tokio and hyper. For instance, reqwest + includes default features like TLS through rustls. If default features remain + enabled, it inflates the size with many additional components. I notice + that for musl targets, using vendored builds of OpenSSL could significantly + increase binary size. This is quite the intricate web of dependencies! + + Analyzing musl and binary size + + I’m considering the implications of building for musl with static linking. + In this case, OpenSSL might come into play through some crates, but reqwest + defaults to using rustls, although openssl-sys vendored could increase + binary size. It’s interesting to think about whether they’re targeting musl, + especially since it seems they might often be building for Mac and Linux with + dynamic linking instead. + + I also realize there’s a difference between debug and release builds — release + should be smaller due to optimizations, but if they’re defaulting to debug + builds, that could lead to much larger binaries. + + Assessing crate impacts on size + + I need to be precise when analyzing binary sizes from various crates. Some + crates, like ratatui-image, include heavy features such as libjpeg decoders, + which can really bulk things up. Then there are the tree-sitter crates that + contain large static grammar tables, adding significant size. The starlark + crate and tracing-subscriber also contribute to size increases. + + With static linking per binary being standard in Rust, it leads to multiple + copies of the same dependencies unless cdylibs are used. That makes the + compile times longer, but using LTO helps reduce bloat somewhat through + deduplication. + + Evaluating binary size factors + + Having multiple binaries indeed increases the overall size significantly. + I'm considering options like upgrading to "opt-level = 'z'" or using + "strip=debuginfo" for reducing size. It's interesting that while the profile + only sets symbols for stripping, not debuginfo, this could inflate sizes if + retained in release builds. + + The distinction between "symbols" and "debuginfo" is critical; "symbols" + keeps debug information which may not align with the goal of minimizing + size. I’d like to verify with the Rust documentation, but my network access + is restricted. + + Explaining large binary sizes + + I’m analyzing why binaries in this project are so large. The primary reasons + include monolithic static linking, where each binary includes the full + dependency graph without shared libraries. Heavy dependencies such as reqwest, + hyper, and TLS components also contribute significantly to size, along with + image decoders and other libraries. + + Using MUSL targets means vendored OpenSSL can drastically increase the + size due to static linking. Furthermore, if the release profile keeps debug + information due to using "strip = symbols," that can lead to larger binaries + as well. It’s a mix of several factors, including panic strategies and + platform-specific linking behavior. + + Identifying binary size contributors + + I’m considering why binaries are still large even without static linking. The + tracing-subscriber crate potentially adds weight because it includes regex, + though it can sometimes use a lighter version. References from files like + "core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses + the image crate. + + To present this clearly, I’ll format the final answer with two main sections: + "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for + reducing size, but I want to stay focused on answering the user's question. + +─ Worked for 0s ──────────────────────────────────────────────────────────────── + +• Here’s what’s driving size in this workspace’s binaries. + + Main Causes + + - Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp- + server, etc.) statically links its full dependency graph, so common code isn’t + shared at runtime across executables. + - Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors, + and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui, + and ollama you enable reqwest with json/stream, which still pulls a large + transitive set. + - Image/terminal stack: codex-tui includes image (with jpeg), ratatui, + crossterm, and ratatui-image, which together contribute significant code size + (decoders, pixel ops, terminal backends). + - Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and + starlark (in execpolicy) include sizeable parser/VM tables and runtimes. + - Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, + signal) across many crates inflate code size even if only subsets are used + per bin. + - Panic + backtraces: Default panic = unwind and backtrace support keep + unwinding tables and symbols that add weight. + - Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl- + sys with vendored, compiling OpenSSL into the binary—this adds multiple + megabytes per executable. + + Build-Mode Notes + + - Release settings: You use lto = "fat" and codegen-units = 1 (good for size), + but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single + contributor; if you build in release with that setting, binaries can still + be large. + - Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and + assertions—outputs are much larger than cargo build --release. + + If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt- + level = "z", panic abort, tighter tokio/reqwest features) and estimate impact + per binary. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap new file mode 100644 index 0000000000..1e73a237eb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap new file mode 100644 index 0000000000..7a04b0ef19 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap new file mode 100644 index 0000000000..4487d0652e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap new file mode 100644 index 0000000000..1e73a237eb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap new file mode 100644 index 0000000000..7a04b0ef19 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap new file mode 100644 index 0000000000..4487d0652e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap new file mode 100644 index 0000000000..c3bdf60bd2 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + +• Investigating rendering code (0s • esc to interrupt) + + +› Summarize recent commits + + 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap new file mode 100644 index 0000000000..1ed73b5fa5 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +• -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + + ```sh + printf 'fenced within fenced\n' + ``` + + { + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" + } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 0000000000..6d9aa515b1 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap new file mode 100644 index 0000000000..6062087181 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap new file mode 100644 index 0000000000..e8f08a437a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob +--- +■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap new file mode 100644 index 0000000000..f04e1f078a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 495 +expression: lines_to_single_string(&aborted_long) +--- +✗ You canceled the request to run echo + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap new file mode 100644 index 0000000000..d35cb17597 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_multi) +--- +✗ You canceled the request to run echo line1 ... + diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap new file mode 100644 index 0000000000..2f0f1412a1 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&decision) +--- +✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap new file mode 100644 index 0000000000..1c6a3ef136 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 13 }, + content: [ + " ", + " ", + " Would you like to run the following command? ", + " ", + " Reason: this is a test reason such as one that would be produced by the ", + " model ", + " ", + " $ echo hello world ", + " ", + "› 1. Yes, proceed (y) ", + " 2. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap new file mode 100644 index 0000000000..588a9503eb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob1 +--- +• Exploring + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap new file mode 100644 index 0000000000..492e8b7708 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob2 +--- +• Explored + └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap new file mode 100644 index 0000000000..2ce4170929 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob3 +--- +• Exploring + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap new file mode 100644 index 0000000000..9e29785f71 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob4 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap new file mode 100644 index 0000000000..296b00f905 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob5 +--- +• Explored + └ List ls -la + Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap new file mode 100644 index 0000000000..55fa979123 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob6 +--- +• Explored + └ List ls -la + Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap new file mode 100644 index 0000000000..4a98242027 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + How was this? + +› 1. bug Crash, error message, hang, or broken UI/behavior. + 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. + 3. good result Helpful, correct, high‑quality, or delightful result worth + celebrating. + 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap new file mode 100644 index 0000000000..cc3d8e3755 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap new file mode 100644 index 0000000000..6062087181 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 0000000000..71dac5f590 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap new file mode 100644 index 0000000000..59eff20ace --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: exec_blob +--- +• Ran sleep 1 + └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 0000000000..60715e581e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: last +--- +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap new file mode 100644 index 0000000000..cf4c6943fd --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Viewed Image + └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap new file mode 100644 index 0000000000..b4b89736a9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday tasks +› 3. High (current) Maximizes reasoning depth for complex problems + 4. Extra high Extra high reasoning depth for complex problems + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap new file mode 100644 index 0000000000..c5332ff590 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1548 +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex-max + + 1. Low Fast responses with lighter reasoning + 2. Medium (default) Balances speed and reasoning depth for everyday + tasks + 3. High Maximizes reasoning depth for complex problems +› 4. Extra high (current) Extra high reasoning depth for complex problems + ⚠ Extra high reasoning effort can quickly consume + Plus plan rate limits. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap new file mode 100644 index 0000000000..56a209ef73 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap new file mode 100644 index 0000000000..e210d1f0a3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Approaching rate limits + Switch to gpt-5.1-codex-mini for lower credit usage? + +› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, + faster, but less capable. + 2. Keep current model + 3. Keep current model (never show again) Hide future rate limit reminders + about switching models. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap new file mode 100644 index 0000000000..9fbebfb500 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1577 +expression: terminal.backend() +--- +" " +"• Analyzing (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap new file mode 100644 index 0000000000..5e6e33dece --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend() +--- +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap new file mode 100644 index 0000000000..6a49cb253c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + ✨ New version available! Would you like to update? + + Full release notes: https://github.com/openai/codex/releases/latest + + +› 1. Yes, update now + 2. No, not now + 3. Don't remind me + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap new file mode 100644 index 0000000000..c67cd637d7 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: blob +--- +• You ran ls + └ file1 + file2 diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs new file mode 100644 index 0000000000..d9e242674b --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -0,0 +1,3329 @@ +use super::*; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::test_backend::VT100Backend; +use crate::tui::FrameRequester; +use assert_matches::assert_matches; +use codex_common::approval_presets::builtin_approval_presets; +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AgentMessageDeltaEvent; +use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::AgentReasoningDeltaEvent; +use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CreditsSnapshot; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExecPolicyAmendment; +use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::FileChange; +use codex_core::protocol::Op; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitWindow; +use codex_core::protocol::ReviewCodeLocation; +use codex_core::protocol::ReviewFinding; +use codex_core::protocol::ReviewLineRange; +use codex_core::protocol::ReviewOutputEvent; +use codex_core::protocol::ReviewRequest; +use codex_core::protocol::ReviewTarget; +use codex_core::protocol::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TokenCountEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; +use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::plan_tool::PlanItemArg; +use codex_protocol::plan_tool::StepStatus; +use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::CodexErrorInfo; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use std::path::PathBuf; +use tempfile::NamedTempFile; +use tempfile::tempdir; +use tokio::sync::mpsc::error::TryRecvError; +use tokio::sync::mpsc::unbounded_channel; + +#[cfg(target_os = "windows")] +fn set_windows_sandbox_enabled(enabled: bool) { + codex_core::set_windows_sandbox_enabled(enabled); +} + +fn test_config() -> Config { + // Use base defaults to avoid depending on host state. + + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("config") +} + +fn snapshot(percent: f64) -> RateLimitSnapshot { + RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: percent, + window_minutes: Some(60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + } +} + +#[test] +fn resumed_initial_messages_render_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![ + EventMsg::UserMessage(UserMessageEvent { + message: "hello from user".to_string(), + images: None, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "assistant reply".to_string(), + }), + ]), + skill_load_outcome: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let cells = drain_insert_history(&mut rx); + let mut merged_lines = Vec::new(); + for lines in cells { + let text = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.clone()) + .collect::(); + merged_lines.push(text); + } + + let text_blob = merged_lines.join("\n"); + assert!( + text_blob.contains("hello from user"), + "expected replayed user message", + ); + assert!( + text_blob.contains("assistant reply"), + "expected replayed agent message", + ); +} + +/// Entering review mode uses the hint provided by the review request. +#[test] +fn entered_review_mode_uses_request_hint() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: feature branch <<\n"); + assert!(chat.is_review_mode); +} + +/// Entering review mode renders the current changes banner when requested. +#[test] +fn entered_review_mode_defaults_to_current_changes_banner() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("review banner")); + assert_eq!(banner, ">> Code review started: current changes <<\n"); + assert!(chat.is_review_mode); +} + +/// Completing review with findings shows the selection popup and finishes with +/// the closing banner while clearing review mode state. +#[test] +fn exited_review_mode_emits_results_and_finishes() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let review = ReviewOutputEvent { + findings: vec![ReviewFinding { + title: "[P1] Fix bug".to_string(), + body: "Something went wrong".to_string(), + confidence_score: 0.9, + priority: 1, + code_location: ReviewCodeLocation { + absolute_file_path: PathBuf::from("src/lib.rs"), + line_range: ReviewLineRange { start: 10, end: 12 }, + }, + }], + overall_correctness: "needs work".to_string(), + overall_explanation: "Investigate the failure".to_string(), + overall_confidence_score: 0.5, + }; + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: Some(review), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("finished banner")); + assert_eq!(banner, "\n<< Code review finished >>\n"); + assert!(!chat.is_review_mode); +} + +/// Exiting review restores the pre-review context window indicator. +#[test] +fn review_restores_context_window_indicator() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let context_window = 13_000; + let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. + let review_tokens = 12_030; // ~97% remaining after subtracting baseline. + + chat.handle_codex_event(Event { + id: "token-before".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(pre_review_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "feature".to_string(), + }, + user_facing_hint: Some("feature branch".to_string()), + }), + }); + + chat.handle_codex_event(Event { + id: "token-review".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(review_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(97)); + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + assert!(!chat.is_review_mode); +} + +/// Receiving a TokenCount event without usage clears the context indicator. +#[test] +fn token_count_none_resets_context_indicator() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); + + let context_window = 13_000; + let pre_compact_tokens = 12_700; + + chat.handle_codex_event(Event { + id: "token-before".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(make_token_info(pre_compact_tokens, context_window)), + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); + + chat.handle_codex_event(Event { + id: "token-cleared".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + }), + }); + assert_eq!(chat.bottom_pane.context_window_percent(), None); +} + +#[test] +fn context_indicator_shows_used_tokens_when_window_unknown() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); + + chat.config.model_context_window = None; + let auto_compact_limit = 200_000; + chat.config.model_auto_compact_token_limit = Some(auto_compact_limit); + + // No model window, so the indicator should fall back to showing tokens used. + let total_tokens = 106_000; + let token_usage = TokenUsage { + total_tokens, + ..TokenUsage::default() + }; + let token_info = TokenUsageInfo { + total_token_usage: token_usage.clone(), + last_token_usage: token_usage, + model_context_window: None, + }; + + chat.handle_codex_event(Event { + id: "token-usage".into(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(token_info), + rate_limits: None, + }), + }); + + assert_eq!(chat.bottom_pane.context_window_percent(), None); + assert_eq!( + chat.bottom_pane.context_window_used_tokens(), + Some(total_tokens) + ); +} + +#[cfg_attr( + target_os = "macos", + ignore = "system configuration APIs are blocked under macOS seatbelt" +)] +#[tokio::test] +async fn helpers_are_available_and_do_not_panic() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cfg = test_config(); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let init = ChatWidgetInit { + config: cfg, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: tx, + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: false, + auth_manager, + models_manager: conversation_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + skills: None, + is_first_run: true, + model_family, + }; + let mut w = ChatWidget::new(init, conversation_manager); + // Basic construction sanity. + let _ = &mut w; +} + +// --- Helpers for tests that need direct construction and event draining --- +fn make_chatwidget_manual( + model_override: Option<&str>, +) -> ( + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (tx_raw, rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let (op_tx, op_rx) = unbounded_channel::(); + let mut cfg = test_config(); + let resolved_model = model_override + .map(str::to_owned) + .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); + if let Some(model) = model_override { + cfg.model = Some(model.to_string()); + } + let bottom = BottomPane::new(BottomPaneParams { + app_event_tx: app_event_tx.clone(), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: cfg.animations, + skills: None, + }); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let widget = ChatWidget { + app_event_tx, + codex_op_tx: op_tx, + bottom_pane: bottom, + active_cell: None, + config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), + auth_manager: auth_manager.clone(), + models_manager: Arc::new(ModelsManager::new(auth_manager)), + session_header: SessionHeader::new(resolved_model.clone()), + initial_user_message: None, + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + task_complete_pending: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + conversation_id: None, + frame_requester: FrameRequester::test_dummy(), + show_welcome_banner: true, + queued_user_messages: VecDeque::new(), + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), + current_rollout_path: None, + }; + (widget, rx, op_rx) +} + +fn set_chatgpt_auth(chat: &mut ChatWidget) { + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.clone())); +} + +pub(crate) fn make_chatwidget_manual_with_sender() -> ( + ChatWidget, + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (widget, rx, op_rx) = make_chatwidget_manual(None); + let app_event_tx = widget.app_event_tx.clone(); + (widget, app_event_tx, rx, op_rx) +} + +fn drain_insert_history( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec>> { + let mut out = Vec::new(); + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + out.push(lines) + } + } + out +} + +fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { + let mut s = String::new(); + for line in lines { + for span in &line.spans { + s.push_str(&span.content); + } + s.push('\n'); + } + s +} + +fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { + fn usage(total_tokens: i64) -> TokenUsage { + TokenUsage { + total_tokens, + ..TokenUsage::default() + } + } + + TokenUsageInfo { + total_token_usage: usage(total_tokens), + last_token_usage: usage(total_tokens), + model_context_window: Some(context_window), + } +} + +#[test] +fn rate_limit_warnings_emit_thresholds() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); + warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); + warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); + warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); + + assert_eq!( + warnings, + vec![ + String::from( + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." + ), + String::from( + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", + ), + String::from( + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." + ), + String::from( + "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", + ), + ], + "expected one warning per limit for the highest crossed threshold" + ); +} + +#[test] +fn test_rate_limit_warnings_monthly() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(Some(75.0), Some(43199), None, None)); + assert_eq!( + warnings, + vec![String::from( + "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", + ),], + "expected one warning per limit for the highest crossed threshold" + ); +} + +#[test] +fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("17.5".to_string()), + }), + plan_type: None, + })); + let initial_balance = chat + .rate_limit_snapshot + .as_ref() + .and_then(|snapshot| snapshot.credits.as_ref()) + .and_then(|credits| credits.balance.as_deref()); + assert_eq!(initial_balance, Some("17.5")); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 80.0, + window_minutes: Some(60), + resets_at: Some(123), + }), + secondary: None, + credits: None, + plan_type: None, + })); + + let display = chat + .rate_limit_snapshot + .as_ref() + .expect("rate limits should be cached"); + let credits = display + .credits + .as_ref() + .expect("credits should persist when headers omit them"); + + assert_eq!(credits.balance.as_deref(), Some("17.5")); + assert!(!credits.unlimited); + assert_eq!( + display.primary.as_ref().map(|window| window.used_percent), + Some(80.0) + ); +} + +#[test] +fn rate_limit_snapshot_updates_and_retains_plan_type() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 10.0, + window_minutes: Some(60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(300), + resets_at: None, + }), + credits: None, + plan_type: Some(PlanType::Plus), + })); + assert_eq!(chat.plan_type, Some(PlanType::Plus)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25.0, + window_minutes: Some(30), + resets_at: Some(123), + }), + secondary: Some(RateLimitWindow { + used_percent: 15.0, + window_minutes: Some(300), + resets_at: Some(234), + }), + credits: None, + plan_type: Some(PlanType::Pro), + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(60), + resets_at: Some(456), + }), + secondary: Some(RateLimitWindow { + used_percent: 18.0, + window_minutes: Some(300), + resets_at: Some(567), + }), + credits: None, + plan_type: None, + })); + assert_eq!(chat.plan_type, Some(PlanType::Pro)); +} + +#[test] +fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[test] +fn rate_limit_switch_prompt_shows_once_per_session() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!( + chat.rate_limit_warnings.primary_index >= 1, + "warnings not emitted" + ); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_respects_hidden_notice() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + chat.config.notices.hide_rate_limit_model_nudge = Some(true); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[test] +fn rate_limit_switch_prompt_defers_until_task_complete() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + + chat.bottom_pane.set_task_running(true); + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + + chat.bottom_pane.set_task_running(false); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + + chat.on_rate_limit_snapshot(Some(snapshot(92.0))); + chat.maybe_show_pending_rate_limit_prompt(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("rate_limit_switch_prompt_popup", popup); +} + +// (removed experimental resize snapshot test) + +#[test] +fn exec_approval_emits_proposed_command_and_decision_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Trigger an exec approval request with a short, single-line command + let ev = ExecApprovalRequestEvent { + call_id: "call-short".into(), + turn_id: "turn-short".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-short".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let proposed_cells = drain_insert_history(&mut rx); + assert!( + proposed_cells.is_empty(), + "expected approval request to render via modal without emitting history cells" + ); + + // The approval modal should display the command snippet for user confirmation. + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); + + // Approve via keyboard and verify a concise decision history line is added + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let decision = drain_insert_history(&mut rx) + .pop() + .expect("expected decision cell in history"); + assert_snapshot!( + "exec_approval_history_decision_approved_short", + lines_to_single_string(&decision) + ); +} + +#[test] +fn exec_approval_decision_truncates_multiline_and_long_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Multiline command: modal should show full command, history records decision only + let ev_multi = ExecApprovalRequestEvent { + call_id: "call-multi".into(), + turn_id: "turn-multi".into(), + command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-multi".into(), + msg: EventMsg::ExecApprovalRequest(ev_multi), + }); + let proposed_multi = drain_insert_history(&mut rx); + assert!( + proposed_multi.is_empty(), + "expected multiline approval request to render via modal without emitting history cells" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + let mut saw_first_line = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("echo line1") { + saw_first_line = true; + break; + } + } + assert!( + saw_first_line, + "expected modal to show first line of multiline snippet" + ); + + // Deny via keyboard; decision snippet should be single-line and elided with " ..." + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let aborted_multi = drain_insert_history(&mut rx) + .pop() + .expect("expected aborted decision cell (multiline)"); + assert_snapshot!( + "exec_approval_history_decision_aborted_multiline", + lines_to_single_string(&aborted_multi) + ); + + // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... + let long = format!("echo {}", "a".repeat(200)); + let ev_long = ExecApprovalRequestEvent { + call_id: "call-long".into(), + turn_id: "turn-long".into(), + command: vec!["bash".into(), "-lc".into(), long], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + proposed_execpolicy_amendment: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-long".into(), + msg: EventMsg::ExecApprovalRequest(ev_long), + }); + let proposed_long = drain_insert_history(&mut rx); + assert!( + proposed_long.is_empty(), + "expected long approval request to avoid emitting history cells before decision" + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let aborted_long = drain_insert_history(&mut rx) + .pop() + .expect("expected aborted decision cell (long)"); + assert_snapshot!( + "exec_approval_history_decision_aborted_long", + lines_to_single_string(&aborted_long) + ); +} + +// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- +fn begin_exec_with_source( + chat: &mut ChatWidget, + call_id: &str, + raw_cmd: &str, + source: ExecCommandSource, +) -> ExecCommandBeginEvent { + // Build the full command vec and parse it using core's parser, + // then convert to protocol variants for the event payload. + let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; + let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let interaction_input = None; + let event = ExecCommandBeginEvent { + call_id: call_id.to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source, + interaction_input, + }; + chat.handle_codex_event(Event { + id: call_id.to_string(), + msg: EventMsg::ExecCommandBegin(event.clone()), + }); + event +} + +fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { + begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) +} + +fn end_exec( + chat: &mut ChatWidget, + begin_event: ExecCommandBeginEvent, + stdout: &str, + stderr: &str, + exit_code: i32, +) { + let aggregated = if stderr.is_empty() { + stdout.to_string() + } else { + format!("{stdout}{stderr}") + }; + let ExecCommandBeginEvent { + call_id, + turn_id, + command, + cwd, + parsed_cmd, + source, + interaction_input, + process_id, + } = begin_event; + chat.handle_codex_event(Event { + id: call_id.clone(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + process_id, + turn_id, + command, + cwd, + parsed_cmd, + source, + interaction_input, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + aggregated_output: aggregated.clone(), + exit_code, + duration: std::time::Duration::from_millis(5), + formatted_output: aggregated, + }), + }); +} + +fn active_blob(chat: &ChatWidget) -> String { + let lines = chat + .active_cell + .as_ref() + .expect("active cell present") + .display_lines(80); + lines_to_single_string(&lines) +} + +fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { + let models = chat + .models_manager + .try_list_models() + .expect("models lock available"); + models + .iter() + .find(|&preset| preset.model == model) + .cloned() + .unwrap_or_else(|| panic!("{model} preset not found")) +} + +#[test] +fn empty_enter_during_task_does_not_queue() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate running task so submissions would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Press Enter with an empty composer. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Ensure nothing was queued. + assert!(chat.queued_user_messages.is_empty()); +} + +#[test] +fn alt_up_edits_most_recent_queued_message() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate a running task so messages would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Seed two queued messages. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Press Alt+Up to edit the most recent (last) queued message. + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + + // Composer should now contain the last queued message. + assert_eq!( + chat.bottom_pane.composer_text(), + "second queued".to_string() + ); + // And the queue should now contain only the remaining (older) item. + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first queued" + ); +} + +/// Pressing Up to recall the most recent history entry and immediately queuing +/// it while a task is running should always enqueue the same text, even when it +/// is queued repeatedly. +#[test] +fn enqueueing_history_prompt_multiple_times_is_stable() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Submit an initial prompt to seed history. + chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Simulate an active task so further submissions are queued. + chat.bottom_pane.set_task_running(true); + + for _ in 0..3 { + // Recall the prompt from history and ensure it is what we expect. + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); + + // Queue the prompt while the task is running. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + + assert_eq!(chat.queued_user_messages.len(), 3); + for message in chat.queued_user_messages.iter() { + assert_eq!(message.text, "repeat me"); + } +} + +#[test] +fn streaming_final_answer_keeps_task_running_state() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + chat.on_commit_tick(); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_widget().is_none()); + + chat.bottom_pane + .set_composer_text("queued submission".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "queued submission" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); +} + +#[test] +fn ctrl_c_shutdown_ignores_caps_lock() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + other => panic!("expected Op::Shutdown, got {other:?}"), + } +} + +#[test] +fn ctrl_c_cleared_prompt_is_recoverable_via_history() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.bottom_pane.insert_str("draft message "); + chat.bottom_pane + .attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png"); + let placeholder = "[preview.png 24x42]"; + assert!( + chat.bottom_pane.composer_text().ends_with(placeholder), + "expected placeholder {placeholder:?} in composer text" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let restored_text = chat.bottom_pane.composer_text(); + assert!( + restored_text.ends_with(placeholder), + "expected placeholder {placeholder:?} after history recall" + ); + assert!(restored_text.starts_with("draft message ")); + assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible()); + + let images = chat.bottom_pane.take_recent_submission_images(); + assert!( + images.is_empty(), + "attachments are not preserved in history recall" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_completed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin command + let begin = begin_exec(&mut chat, "call-1", "echo done"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); + + // End command successfully + end_exec(&mut chat, begin, "done", "", 0); + + let cells = drain_insert_history(&mut rx); + // Exec end now finalizes and flushes the exec cell immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + // Inspect the flushed exec cell rendering. + let lines = &cells[0]; + let blob = lines_to_single_string(lines); + // New behavior: no glyph markers; ensure command is shown and no panic. + assert!( + blob.contains("• Ran"), + "expected summary header present: {blob:?}" + ); + assert!( + blob.contains("echo done"), + "expected command text to be present: {blob:?}" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_failed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin command + let begin = begin_exec(&mut chat, "call-2", "false"); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); + + // End command with failure + end_exec(&mut chat, begin, "", "Bloop", 2); + + let cells = drain_insert_history(&mut rx); + // Exec end with failure should also flush immediately. + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let lines = &cells[0]; + let blob = lines_to_single_string(lines); + assert!( + blob.contains("• Ran false"), + "expected command and header text present: {blob:?}" + ); + assert!(blob.to_lowercase().contains("bloop"), "expected error text"); +} + +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + +#[test] +fn exec_history_shows_unified_exec_startup_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let begin = begin_exec_with_source( + &mut chat, + "call-startup", + "echo unified exec startup", + ExecCommandSource::UnifiedExecStartup, + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "exec begin should not flush until completion" + ); + + end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo unified exec startup"), + "expected startup command to render: {blob:?}" + ); +} + +/// Selecting the custom prompt option from the review popup sends +/// OpenReviewCustomPrompt to the app event channel. +#[test] +fn review_popup_custom_prompt_action_sends_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Open the preset selection popup + chat.open_review_popup(); + + // Move selection down to the fourth item: "Custom review instructions" + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + // Activate + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Drain events and ensure we saw the OpenReviewCustomPrompt request + let mut found = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::OpenReviewCustomPrompt = ev { + found = true; + break; + } + } + assert!(found, "expected OpenReviewCustomPrompt event to be sent"); +} + +#[test] +fn slash_init_skips_when_project_doc_exists() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + let tempdir = tempdir().unwrap(); + let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); + std::fs::write(&existing_path, "existing instructions").unwrap(); + chat.config.cwd = tempdir.path().to_path_buf(); + + chat.dispatch_command(SlashCommand::Init); + + match op_rx.try_recv() { + Err(TryRecvError::Empty) => {} + other => panic!("expected no Codex op to be sent, got {other:?}"), + } + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), + "info message should mention the existing file: {rendered:?}" + ); + assert!( + rendered.contains("Skipping /init"), + "info message should explain why /init was skipped: {rendered:?}" + ); + assert_eq!( + std::fs::read_to_string(existing_path).unwrap(), + "existing instructions" + ); +} + +#[test] +fn slash_quit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Quit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_exit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Exit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + +#[test] +fn slash_undo_sends_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Undo); + + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Undo)) => {} + other => panic!("expected AppEvent::CodexOp(Op::Undo), got {other:?}"), + } +} + +#[test] +fn slash_rollout_displays_current_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); + chat.current_rollout_path = Some(rollout_path.clone()); + + chat.dispatch_command(SlashCommand::Rollout); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected info message for rollout path"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(&rollout_path.display().to_string()), + "expected rollout path to be shown: {rendered}" + ); +} + +#[test] +fn slash_rollout_handles_missing_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Rollout); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected info message explaining missing path" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("not available"), + "expected missing rollout path message: {rendered}" + ); +} + +#[test] +fn undo_success_events_render_info_messages() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { + message: Some("Undo requested for the last turn...".to_string()), + }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-1".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: true, + message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after successful undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Undo completed successfully."), + "expected default success message, got {completed:?}" + ); +} + +#[test] +fn undo_failure_events_render_error_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + assert!( + chat.bottom_pane.status_indicator_visible(), + "status indicator should be visible during undo" + ); + + chat.handle_codex_event(Event { + id: "turn-2".to_string(), + msg: EventMsg::UndoCompleted(UndoCompletedEvent { + success: false, + message: Some("Failed to restore workspace state.".to_string()), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected final status only"); + assert!( + !chat.bottom_pane.status_indicator_visible(), + "status indicator should be hidden after failed undo" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Failed to restore workspace state."), + "expected failure message, got {completed:?}" + ); +} + +#[test] +fn undo_started_hides_interrupt_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "turn-hint".to_string(), + msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be active"); + assert!( + !status.interrupt_hint_visible(), + "undo should hide the interrupt hint because the operation cannot be cancelled" + ); +} + +/// The commit picker shows only commit subjects (no timestamps). +#[test] +fn review_commit_picker_shows_subjects_without_timestamps() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Show commit picker with synthetic entries. + let entries = vec![ + codex_core::git_info::CommitLogEntry { + sha: "1111111deadbeef".to_string(), + timestamp: 0, + subject: "Add new feature X".to_string(), + }, + codex_core::git_info::CommitLogEntry { + sha: "2222222cafebabe".to_string(), + timestamp: 0, + subject: "Fix bug Y".to_string(), + }, + ]; + super::show_review_commit_picker_with_entries(&mut chat, entries); + + // Render the bottom pane and inspect the lines for subjects and absence of time words. + let width = 72; + let height = chat.desired_height(width); + let area = ratatui::layout::Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + + let mut blob = String::new(); + for y in 0..area.height { + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + blob.push(' '); + } else { + blob.push_str(s); + } + } + blob.push('\n'); + } + + assert!( + blob.contains("Add new feature X"), + "expected subject in output" + ); + assert!(blob.contains("Fix bug Y"), "expected subject in output"); + + // Ensure no relative-time phrasing is present. + let lowered = blob.to_lowercase(); + assert!( + !lowered.contains("ago") + && !lowered.contains(" second") + && !lowered.contains(" minute") + && !lowered.contains(" hour") + && !lowered.contains(" day"), + "expected no relative time in commit picker output: {blob:?}" + ); +} + +/// Submitting the custom prompt view sends Op::Review with the typed prompt +/// and uses the same text for the user-facing hint. +#[test] +fn custom_prompt_submit_sends_review_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.show_review_custom_prompt(); + // Paste prompt text via ChatWidget handler, then submit + chat.handle_paste(" please audit dependencies ".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + let evt = rx.try_recv().expect("expected one app event"); + match evt { + AppEvent::CodexOp(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "please audit dependencies".to_string(), + }, + user_facing_hint: None, + } + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +/// Hitting Enter on an empty custom prompt view does not submit. +#[test] +fn custom_prompt_enter_empty_does_not_send() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.show_review_custom_prompt(); + // Enter without any text + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // No AppEvent::CodexOp should be sent + assert!(rx.try_recv().is_err(), "no app event should be sent"); +} + +#[test] +fn view_image_tool_call_adds_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let image_path = chat.config.cwd.join("example.png"); + + chat.handle_codex_event(Event { + id: "sub-image".into(), + msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { + call_id: "call-image".into(), + path: image_path, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single history cell"); + let combined = lines_to_single_string(&cells[0]); + assert_snapshot!("local_image_attachment_history_snapshot", combined); +} + +// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ +// marker (replacing the spinner) and flushes it into history. +#[test] +fn interrupt_exec_marks_failed_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin a long-running command so we have an active exec cell with a spinner. + begin_exec(&mut chat, "call-int", "sleep 1"); + + // Simulate the task being aborted (as if ESC was pressed), which should + // cause the active exec cell to be finalized as failed and flushed. + chat.handle_codex_event(Event { + id: "call-int".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected finalized exec cell to be inserted into history" + ); + + // The first inserted cell should be the finalized exec; snapshot its text. + let exec_blob = lines_to_single_string(&cells[0]); + assert_snapshot!("interrupt_exec_marks_failed", exec_blob); +} + +// Snapshot test: after an interrupted turn, a gentle error message is inserted +// suggesting the user to tell the model what to do differently and to use /feedback. +#[test] +fn interrupted_turn_error_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate an in-progress task so the widget is in a running state. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // Abort the turn (like pressing Esc) and drain inserted history. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected error message to be inserted after interruption" + ); + let last = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!("interrupted_turn_error_message", last); +} + +/// Opening custom prompt from the review popup, pressing Esc returns to the +/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +#[test] +fn review_custom_prompt_escape_navigates_back_then_dismisses() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Open the custom prompt submenu (child view) directly. + chat.show_review_custom_prompt(); + + // Verify child view is on top. + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Custom review instructions"), + "expected custom prompt view header: {header:?}" + ); + + // Esc once: child view closes, parent (review presets) remains. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a review preset"), + "expected to return to parent review popup: {header:?}" + ); + + // Esc again: parent closes; back to normal composer state. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + chat.is_normal_backtrack_mode(), + "expected to be back in normal composer mode" + ); +} + +/// Opening base-branch picker from the review popup, pressing Esc returns to the +/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +#[tokio::test] +async fn review_branch_picker_escape_navigates_back_then_dismisses() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the Review presets parent popup. + chat.open_review_popup(); + + // Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine. + let cwd = std::env::temp_dir(); + chat.show_review_branch_picker(&cwd).await; + + // Verify child view header. + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a base branch"), + "expected branch picker header: {header:?}" + ); + + // Esc once: child view closes, parent remains. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let header = render_bottom_first_row(&chat, 60); + assert!( + header.contains("Select a review preset"), + "expected to return to parent review popup: {header:?}" + ); + + // Esc again: parent closes; back to normal composer state. + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + chat.is_normal_backtrack_mode(), + "expected to be back in normal composer mode" + ); +} + +fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { + let height = chat.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + chat.render(area, &mut buf); + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + let s = buf[(x, y)].symbol(); + if s.is_empty() { + row.push(' '); + } else { + row.push_str(s); + } + } + if !row.trim().is_empty() { + return row; + } + } + String::new() +} + +fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { + let height = chat.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + chat.render(area, &mut buf); + + let mut lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|line| line.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + + lines.join("\n") +} + +#[test] +fn model_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); + chat.open_model_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_selection_popup", popup); +} + +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + chat.config.notices.hide_full_access_warning = None; + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("approvals_selection_popup", popup); + }); + #[cfg(not(target_os = "windows"))] + assert_snapshot!("approvals_selection_popup", popup); +} + +#[test] +fn preset_matching_ignores_extra_writable_roots() { + let preset = builtin_approval_presets() + .into_iter() + .find(|p| p.id == "auto") + .expect("auto preset exists"); + let current_sandbox = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("C:\\extra")], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + assert!( + ChatWidget::preset_matches_current(AskForApproval::OnRequest, ¤t_sandbox, &preset), + "WorkspaceWrite with extra roots should still match the Agent preset" + ); + assert!( + !ChatWidget::preset_matches_current(AskForApproval::Never, ¤t_sandbox, &preset), + "approval mismatch should prevent matching the preset" + ); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .expect("full access preset"); + chat.open_full_access_confirmation(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("full_access_confirmation_popup", popup); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + chat.open_windows_sandbox_enable_prompt(preset); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Agent mode on Windows uses an experimental sandbox"), + "expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}" + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn startup_prompts_for_windows_sandbox_when_agent_requested() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + set_windows_sandbox_enabled(false); + chat.config.forced_auto_mode_downgraded_on_windows = true; + + chat.maybe_prompt_windows_sandbox_enable(); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Agent mode on Windows uses an experimental sandbox"), + "expected startup prompt to explain sandbox: {popup}" + ); + assert!( + popup.contains("Enable experimental sandbox"), + "expected startup prompt to offer enabling the sandbox: {popup}" + ); + + set_windows_sandbox_enabled(true); +} + +#[test] +fn model_reasoning_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_reasoning_selection_popup", popup); +} + +#[test] +fn model_reasoning_selection_popup_extra_high_warning_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); +} + +#[test] +fn reasoning_popup_shows_extra_high_with_space() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + set_chatgpt_auth(&mut chat); + + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 120); + assert!( + popup.contains("Extra high"), + "expected popup to include 'Extra high'; popup: {popup}" + ); + assert!( + !popup.contains("Extrahigh"), + "expected popup not to include 'Extrahigh'; popup: {popup}" + ); +} + +#[test] +fn single_reasoning_option_skips_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let single_effort = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::High, + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), + }]; + let preset = ModelPreset { + id: "model-with-single-reasoning".to_string(), + model: "model-with-single-reasoning".to_string(), + display_name: "model-with-single-reasoning".to_string(), + description: "".to_string(), + default_reasoning_effort: ReasoningEffortConfig::High, + supported_reasoning_efforts: single_effort, + is_default: false, + upgrade: None, + show_in_picker: true, + }; + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert!( + !popup.contains("Select Reasoning Level"), + "expected reasoning selection popup to be skipped" + ); + + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + + assert!( + events + .iter() + .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + "expected reasoning effort to be applied automatically; events: {events:?}" + ); +} + +#[test] +fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the feedback category selection popup via slash command. + chat.dispatch_command(SlashCommand::Feedback); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_selection_popup", popup); +} + +#[test] +fn feedback_upload_consent_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // Open the consent popup directly for a chosen category. + chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_upload_consent_popup", popup); +} + +#[test] +fn reasoning_popup_escape_returns_to_model_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); + chat.open_model_popup(); + + let preset = get_available_model(&chat, "gpt-5.1-codex"); + chat.open_reasoning_popup(preset); + + let before_escape = render_bottom_popup(&chat, 80); + assert!(before_escape.contains("Select Reasoning Level")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let after_escape = render_bottom_popup(&chat, 80); + assert!(after_escape.contains("Select Model")); + assert!(!after_escape.contains("Select Reasoning Level")); +} + +#[test] +fn exec_history_extends_previous_when_consecutive() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 1) Start "ls -la" (List) + let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); + assert_snapshot!("exploring_step1_start_ls", active_blob(&chat)); + + // 2) Finish "ls -la" + end_exec(&mut chat, begin_ls, "", "", 0); + assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat)); + + // 3) Start "cat foo.txt" (Read) + let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt"); + assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat)); + + // 4) Complete "cat foo.txt" + end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0); + assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat)); + + // 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt) + let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt"); + end_exec(&mut chat, begin_sed_range, "chunk", "", 0); + assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat)); + + // 6) Start & complete "cat bar.txt" + let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt"); + end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0); + assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); +} + +#[test] +fn user_shell_command_renders_output_not_exploring() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let begin_ls = begin_exec_with_source( + &mut chat, + "user-shell-ls", + "ls", + ExecCommandSource::UserShell, + ); + end_exec(&mut chat, begin_ls, "file1\nfile2\n", "", 0); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected a single history cell for the user command" + ); + let blob = lines_to_single_string(cells.first().unwrap()); + assert_snapshot!("user_shell_ls_output", blob); +} + +#[test] +fn disabled_slash_command_while_task_running_snapshot() { + // Build a chat widget and simulate an active task + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.bottom_pane.set_task_running(true); + + // Dispatch a command that is unavailable while a task runs (e.g., /model) + chat.dispatch_command(SlashCommand::Model); + + // Drain history and snapshot the rendered error line(s) + let cells = drain_insert_history(&mut rx); + assert!( + !cells.is_empty(), + "expected an error message history cell to be emitted", + ); + let blob = lines_to_single_string(cells.last().unwrap()); + assert_snapshot!(blob); +} + +// +// Snapshot test: command approval modal +// +// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal +// and snapshots the visual output using the ratatui TestBackend. +#[test] +fn approval_modal_exec_snapshot() { + // Build a chat widget with manual channels to avoid spawning the agent. + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). + chat.config.approval_policy = AskForApproval::OnRequest; + // Inject an exec approval request to display the approval modal. + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd".into(), + turn_id: "turn-approve-cmd".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + // Render to a fixed-size test terminal and snapshot. + // Call desired_height first and use that exact height for rendering. + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) + .expect("create terminal"); + let viewport = Rect::new(0, 0, width, height); + terminal.set_viewport_area(viewport); + + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal"); + assert!( + terminal + .backend() + .vt100() + .screen() + .contents() + .contains("echo hello world") + ); + assert_snapshot!( + "approval_modal_exec", + terminal.backend().vt100().screen().contents() + ); +} + +// Snapshot test: command approval modal without a reason +// Ensures spacing looks correct when no reason text is provided. +#[test] +fn approval_modal_exec_without_reason_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = AskForApproval::OnRequest; + + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd-noreason".into(), + turn_id: "turn-approve-cmd-noreason".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-noreason".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal (no reason)"); + assert_snapshot!( + "approval_modal_exec_no_reason", + terminal.backend().vt100().screen().contents() + ); +} + +// Snapshot test: patch approval modal +#[test] +fn approval_modal_patch_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = AskForApproval::OnRequest; + + // Build a small changeset and a reason/grant_root to exercise the prompt text. + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Add { + content: "hello\nworld\n".into(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "call-approve-patch".into(), + turn_id: "turn-approve-patch".into(), + changes, + reason: Some("The model wants to apply changes".into()), + grant_root: Some(PathBuf::from("/tmp")), + }; + chat.handle_codex_event(Event { + id: "sub-approve-patch".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + + // Render at the widget's desired height and snapshot. + let height = chat.desired_height(80); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw patch approval modal"); + assert_snapshot!( + "approval_modal_patch", + terminal.backend().vt100().screen().contents() + ); +} + +#[test] +fn interrupt_restores_queued_messages_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // Simulate a running task to enable queuing of user inputs. + chat.bottom_pane.set_task_running(true); + + // Queue two user messages while the task is running. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + // Composer should now contain the queued messages joined by newlines, in order. + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued" + ); + + // Queue should be cleared and no new user input should have been auto-submitted. + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + // Drain rx to avoid unused warnings. + let _ = drain_insert_history(&mut rx); +} + +#[test] +fn interrupt_prepends_queued_messages_before_existing_composer_text() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + chat.bottom_pane.set_task_running(true); + chat.bottom_pane + .set_composer_text("current draft".to_string()); + + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued\ncurrent draft" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + let _ = drain_insert_history(&mut rx); +} + +// Snapshot test: ChatWidget at very small heights (idle) +// Ensures overall layout behaves when terminal height is extremely constrained. +#[test] +fn ui_snapshots_small_heights_idle() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); + for h in [1u16, 2, 3] { + let name = format!("chat_small_idle_h{h}"); + let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat idle"); + assert_snapshot!(name, terminal.backend()); + } +} + +// Snapshot test: ChatWidget at very small heights (task running) +// Validates how status + composer are presented within tight space. +#[test] +fn ui_snapshots_small_heights_task_running() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate status line + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Thinking**".into(), + }), + }); + for h in [1u16, 2, 3] { + let name = format!("chat_small_running_h{h}"); + let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat running"); + assert_snapshot!(name, terminal.backend()); + } +} + +// Snapshot test: status widget + approval modal active together +// The modal takes precedence visually; this captures the layout with a running +// task (status indicator active) while an approval request is shown. +#[test] +fn status_widget_and_approval_modal_snapshot() { + use codex_core::protocol::ExecApprovalRequestEvent; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Begin a running task so the status indicator would be active. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Provide a deterministic header for the status line. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Analyzing**".into(), + }), + }); + + // Now show an approval modal (e.g. exec approval). + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-exec".into(), + turn_id: "turn-approve-exec".into(), + command: vec!["echo".into(), "hello world".into()], + cwd: PathBuf::from("/tmp"), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello world".into(), + ])), + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-exec".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + // Render at the widget's desired height and snapshot. + let width: u16 = 100; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) + .expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw status + approval modal"); + assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); +} + +// Snapshot test: status widget active (StatusIndicatorView) +// Ensures the VT100 rendering of the status indicator is stable when active. +#[test] +fn status_widget_active_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate the status indicator by simulating a task start. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Provide a deterministic header via a bold reasoning chunk. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Analyzing**".into(), + }), + }); + // Render and snapshot. + let height = chat.desired_height(80); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) + .expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw status widget"); + assert_snapshot!("status_widget_active", terminal.backend()); +} + +#[test] +fn background_event_updates_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.handle_codex_event(Event { + id: "bg-1".into(), + msg: EventMsg::BackgroundEvent(BackgroundEventEvent { + message: "Waiting for `vim`".to_string(), + }), + }); + + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.current_status_header, "Waiting for `vim`"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[test] +fn apply_patch_events_emit_history_cells() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 1) Approval request -> proposed patch summary cell + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes, + reason: None, + grant_root: None, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected approval request to surface via modal without emitting history cells" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + let mut saw_summary = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("foo.txt (+1 -0)") { + saw_summary = true; + break; + } + } + assert!(saw_summary, "expected approval modal to show diff summary"); + + // 2) Begin apply -> per-file apply block cell (no global header) + let mut changes2 = HashMap::new(); + changes2.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let begin = PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: true, + changes: changes2, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(begin), + }); + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), + "expected single-file header with filename (Added/Edited): {blob:?}" + ); + + // 3) End apply success -> success cell + let mut end_changes = HashMap::new(); + end_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + let end = PatchApplyEndEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + stdout: "ok\n".into(), + stderr: String::new(), + success: true, + changes: end_changes, + }; + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyEnd(end), + }); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "no success cell should be emitted anymore" + ); +} + +#[test] +fn apply_patch_manual_approval_adjusts_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes: proposed_changes, + reason: None, + grant_root: None, + }), + }); + drain_insert_history(&mut rx); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected apply block cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), + "expected apply summary header for foo.txt: {blob:?}" + ); +} + +#[test] +fn apply_patch_manual_flow_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let mut proposed_changes = HashMap::new(); + proposed_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + changes: proposed_changes, + reason: Some("Manual review required".into()), + grant_root: None, + }), + }); + let history_before_apply = drain_insert_history(&mut rx); + assert!( + history_before_apply.is_empty(), + "expected approval modal to defer history emission" + ); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + PathBuf::from("foo.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: false, + changes: apply_changes, + }), + }); + let approved_lines = drain_insert_history(&mut rx) + .pop() + .expect("approved patch cell"); + + assert_snapshot!( + "apply_patch_manual_flow_history_approved", + lines_to_single_string(&approved_lines) + ); +} + +#[test] +fn apply_patch_approval_sends_op_with_submission_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + // Simulate receiving an approval request with a distinct submission id and call id + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("file.rs"), + FileChange::Add { + content: "fn main(){}\n".into(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "call-999".into(), + turn_id: "turn-999".into(), + changes, + reason: None, + grant_root: None, + }; + chat.handle_codex_event(Event { + id: "sub-123".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + + // Approve via key press 'y' + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + // Expect a CodexOp with PatchApproval carrying the submission id, not call id + let mut found = false; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { + assert_eq!(id, "sub-123"); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + found = true; + break; + } + } + assert!(found, "expected PatchApproval op to be sent"); +} + +#[test] +fn apply_patch_full_flow_integration_like() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // 1) Backend requests approval + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // 2) User approves via 'y' and App receives a CodexOp + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let mut maybe_op: Option = None; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::CodexOp(op) = app_ev { + maybe_op = Some(op); + break; + } + } + let op = maybe_op.expect("expected CodexOp after key press"); + + // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx + chat.submit_op(op); + let forwarded = op_rx + .try_recv() + .expect("expected op forwarded to codex channel"); + match forwarded { + Op::PatchApproval { id, decision } => { + assert_eq!(id, "sub-xyz"); + assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); + } + other => panic!("unexpected op forwarded: {other:?}"), + } + + // 4) Simulate patch begin/end events from backend; ensure history cells are emitted + let mut changes2 = HashMap::new(); + changes2.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + auto_approved: false, + changes: changes2, + }), + }); + let mut end_changes = HashMap::new(); + end_changes.insert( + PathBuf::from("pkg.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-xyz".into(), + msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + stdout: String::from("ok"), + stderr: String::new(), + success: true, + changes: end_changes, + }), + }); +} + +#[test] +fn apply_patch_untrusted_shows_approval_modal() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Ensure approval policy is untrusted (OnRequest) + chat.config.approval_policy = AskForApproval::OnRequest; + + // Simulate a patch approval request from backend + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("a.rs"), + FileChange::Add { content: "".into() }, + ); + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-1".into(), + turn_id: "turn-call-1".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // Render and ensure the approval modal title is present + let area = Rect::new(0, 0, 80, 12); + let mut buf = Buffer::empty(area); + chat.render(area, &mut buf); + + let mut contains_title = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("Would you like to make the following edits?") { + contains_title = true; + break; + } + } + assert!( + contains_title, + "expected approval modal to be visible with title 'Would you like to make the following edits?'" + ); +} + +#[test] +fn apply_patch_request_shows_diff_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Ensure we are in OnRequest so an approval is surfaced + chat.config.approval_policy = AskForApproval::OnRequest; + + // Simulate backend asking to apply a patch adding two lines to README.md + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Add { + // Two lines (no trailing empty line counted) + content: "line one\nline two\n".into(), + }, + ); + chat.handle_codex_event(Event { + id: "sub-apply".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "call-apply".into(), + turn_id: "turn-apply".into(), + changes, + reason: None, + grant_root: None, + }), + }); + + // No history entries yet; the modal should contain the diff summary + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected approval request to render via modal instead of history" + ); + + let area = Rect::new(0, 0, 80, chat.desired_height(80)); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + + let mut saw_header = false; + let mut saw_line1 = false; + let mut saw_line2 = false; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains("README.md (+2 -0)") { + saw_header = true; + } + if row.contains("+line one") { + saw_line1 = true; + } + if row.contains("+line two") { + saw_line2 = true; + } + if saw_header && saw_line1 && saw_line2 { + break; + } + } + assert!(saw_header, "expected modal to show diff header with totals"); + assert!( + saw_line1 && saw_line2, + "expected modal to show per-line diff summary" + ); +} + +#[test] +fn plan_update_renders_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let update = UpdatePlanArgs { + explanation: Some("Adapting plan".to_string()), + plan: vec![ + PlanItemArg { + step: "Explore codebase".into(), + status: StepStatus::Completed, + }, + PlanItemArg { + step: "Implement feature".into(), + status: StepStatus::InProgress, + }, + PlanItemArg { + step: "Write tests".into(), + status: StepStatus::Pending, + }, + ], + }; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::PlanUpdate(update), + }); + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected plan update cell to be sent"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!( + blob.contains("Updated Plan"), + "missing plan header: {blob:?}" + ); + assert!(blob.contains("Explore codebase")); + assert!(blob.contains("Implement feature")); + assert!(blob.contains("Write tests")); +} + +#[test] +fn stream_error_updates_status_indicator() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.bottom_pane.set_task_running(true); + let msg = "Reconnecting... 2/5"; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: msg.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected no history cell for StreamError event" + ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), msg); +} + +#[test] +fn warning_event_adds_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::Warning(WarningEvent { + message: "test warning message".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("test warning message"), + "warning cell missing content: {rendered}" + ); +} + +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "retry".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: "Reconnecting... 1/5".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "delta".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "hello".to_string(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.retry_status_header.is_none()); +} + +#[test] +fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // First finalized assistant message + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "First message".into(), + }), + }); + + // Second finalized assistant message in the same turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Second message".into(), + }), + }); + + // End turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined: String = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect(); + assert!( + combined.contains("First message"), + "missing first message: {combined}" + ); + assert!( + combined.contains("Second message"), + "missing second message: {combined}" + ); + let first_idx = combined.find("First message").unwrap(); + let second_idx = combined.find("Second message").unwrap(); + assert!(first_idx < second_idx, "messages out of order: {combined}"); +} + +#[test] +fn final_reasoning_then_message_without_deltas_are_rendered() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // No deltas; only final reasoning followed by final message. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { + text: "I will first analyze the request.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Here is the result.".into(), + }), + }); + + // Drain history and snapshot the combined visible content. + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!(combined); +} + +#[test] +fn deltas_then_same_final_message_are_rendered_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Stream some reasoning deltas first. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "I will ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "first analyze the ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "request.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { + text: "request.".into(), + }), + }); + + // Then stream answer deltas, followed by the exact same final message. + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "Here is the ".into(), + }), + }); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "result.".into(), + }), + }); + + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Here is the result.".into(), + }), + }); + + // Snapshot the combined visible content to ensure we render as expected + // when deltas are followed by the identical final message. + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!(combined); +} + +// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. +// This renders the final visual as seen in a terminal: history above, then a blank line, +// then the exec block, another blank line, the status line, a blank line, and the composer. +#[test] +fn chatwidget_exec_and_status_layout_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), + }); + + let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; + let parsed_cmd = vec![ + ParsedCommand::Search { + query: Some("Change Approved".into()), + path: None, + cmd: "rg \"Change Approved\"".into(), + }, + ParsedCommand::Read { + name: "diff_render.rs".into(), + cmd: "cat diff_render.rs".into(), + path: "diff_render.rs".into(), + }, + ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + }); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(16000), + formatted_output: String::new(), + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Investigating rendering code**".into(), + }), + }); + chat.bottom_pane + .set_composer_text("Summarize recent commits".to_string()); + + let width: u16 = 80; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 40; + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + + let backend = VT100Backend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks +#[test] +fn chatwidget_markdown_code_blocks_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate a final agent message via streaming deltas instead of a single message + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + // Build a vt100 visual from the history insertions only (no UI overlay) + let width: u16 = 80; + let height: u16 = 50; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + // Place viewport at the last line so that history lines insert above it + term.set_viewport_area(Rect::new(0, height - 1, width, 1)); + + // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). + let source: &str = r#" + + -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + +````markdown +```sh +printf 'fenced within fenced\n' +``` +```` + +```jsonc +{ + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" +} +``` +"#; + + let mut it = source.chars(); + loop { + let mut delta = String::new(); + match it.next() { + Some(c) => delta.push(c), + None => break, + } + if let Some(c2) = it.next() { + delta.push(c2); + } + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), + }); + // Drive commit ticks and drain emitted history lines into the vt100 buffer. + loop { + chat.on_commit_tick(); + let mut inserted_any = false; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = app_ev { + let lines = cell.display_lines(width); + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + inserted_any = true; + } + } + if !inserted_any { + break; + } + } + } + + // Finalize the stream without sending a final AgentMessage, to flush any tail. + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: None, + }), + }); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[test] +fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + for i in 0..30 { + chat.queue_user_message(format!("Hello, world! {i}").into()); + } + let width: u16 = 80; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs new file mode 100644 index 0000000000..b0daa44770 --- /dev/null +++ b/codex-rs/tui2/src/cli.rs @@ -0,0 +1,115 @@ +use clap::Parser; +use clap::ValueHint; +use codex_common::ApprovalModeCliArg; +use codex_common::CliConfigOverrides; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(version)] +pub struct Cli { + /// Optional user prompt to start the session. + #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] + pub prompt: Option, + + /// Optional image(s) to attach to the initial prompt. + #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] + pub images: Vec, + + // Internal controls set by the top-level `codex resume` subcommand. + // These are not exposed as user flags on the base `codex` command. + #[clap(skip)] + pub resume_picker: bool, + + #[clap(skip)] + pub resume_last: bool, + + /// Internal: resume a specific recorded session by id (UUID). Set by the + /// top-level `codex resume ` wrapper; not exposed as a public flag. + #[clap(skip)] + pub resume_session_id: Option, + + /// Internal: show all sessions (disables cwd filtering and shows CWD column). + #[clap(skip)] + pub resume_show_all: bool, + + /// Model the agent should use. + #[arg(long, short = 'm')] + pub model: Option, + + /// Convenience flag to select the local open source model provider. Equivalent to -c + /// model_provider=oss; verifies a local LM Studio or Ollama server is running. + #[arg(long = "oss", default_value_t = false)] + pub oss: bool, + + /// Specify which local provider to use (lmstudio or ollama). + /// If not specified with --oss, will use config default or show selection. + #[arg(long = "local-provider")] + pub oss_provider: Option, + + /// Configuration profile from config.toml to specify default options. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + + /// Select the sandbox policy to use when executing model-generated shell + /// commands. + #[arg(long = "sandbox", short = 's')] + pub sandbox_mode: Option, + + /// Configure when the model requires human approval before executing a command. + #[arg(long = "ask-for-approval", short = 'a')] + pub approval_policy: Option, + + /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). + #[arg(long = "full-auto", default_value_t = false)] + pub full_auto: bool, + + /// Skip all confirmation prompts and execute commands without sandboxing. + /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. + #[arg( + long = "dangerously-bypass-approvals-and-sandbox", + alias = "yolo", + default_value_t = false, + conflicts_with_all = ["approval_policy", "full_auto"] + )] + pub dangerously_bypass_approvals_and_sandbox: bool, + + /// Tell the agent to use the specified directory as its working root. + #[clap(long = "cd", short = 'C', value_name = "DIR")] + pub cwd: Option, + + /// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + #[arg(long = "search", default_value_t = false)] + pub web_search: bool, + + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] + pub add_dir: Vec, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} + +impl From for Cli { + fn from(cli: codex_tui::Cli) -> Self { + Self { + prompt: cli.prompt, + images: cli.images, + resume_picker: cli.resume_picker, + resume_last: cli.resume_last, + resume_session_id: cli.resume_session_id, + resume_show_all: cli.resume_show_all, + model: cli.model, + oss: cli.oss, + oss_provider: cli.oss_provider, + config_profile: cli.config_profile, + sandbox_mode: cli.sandbox_mode, + approval_policy: cli.approval_policy, + full_auto: cli.full_auto, + dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox, + cwd: cli.cwd, + web_search: cli.web_search, + add_dir: cli.add_dir, + config_overrides: cli.config_overrides, + } + } +} diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs new file mode 100644 index 0000000000..5863c728b0 --- /dev/null +++ b/codex-rs/tui2/src/clipboard_paste.rs @@ -0,0 +1,504 @@ +use std::path::Path; +use std::path::PathBuf; +use tempfile::Builder; + +#[derive(Debug, Clone)] +pub enum PasteImageError { + ClipboardUnavailable(String), + NoImage(String), + EncodeFailed(String), + IoError(String), +} + +impl std::fmt::Display for PasteImageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), + PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"), + PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"), + PasteImageError::IoError(msg) => write!(f, "io error: {msg}"), + } + } +} +impl std::error::Error for PasteImageError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncodedImageFormat { + Png, + Jpeg, + Other, +} + +impl EncodedImageFormat { + pub fn label(self) -> &'static str { + match self { + EncodedImageFormat::Png => "PNG", + EncodedImageFormat::Jpeg => "JPEG", + EncodedImageFormat::Other => "IMG", + } + } +} + +#[derive(Debug, Clone)] +pub struct PastedImageInfo { + pub width: u32, + pub height: u32, + pub encoded_format: EncodedImageFormat, // Always PNG for now. +} + +/// Capture image from system clipboard, encode to PNG, and return bytes + info. +#[cfg(not(target_os = "android"))] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + let _span = tracing::debug_span!("paste_image_as_png").entered(); + tracing::debug!("attempting clipboard image read"); + let mut cb = arboard::Clipboard::new() + .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?; + // Sometimes images on the clipboard come as files (e.g. when copy/pasting from + // Finder), sometimes they come as image data (e.g. when pasting from Chrome). + // Accept both, and prefer files if both are present. + let files = cb + .get() + .file_list() + .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string())); + let dyn_img = if let Some(img) = files + .unwrap_or_default() + .into_iter() + .find_map(|f| image::open(f).ok()) + { + tracing::debug!( + "clipboard image opened from file: {}x{}", + img.width(), + img.height() + ); + img + } else { + let _span = tracing::debug_span!("get_image").entered(); + let img = cb + .get_image() + .map_err(|e| PasteImageError::NoImage(e.to_string()))?; + let w = img.width as u32; + let h = img.height as u32; + tracing::debug!("clipboard image opened from image: {}x{}", w, h); + + let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else { + return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into())); + }; + + image::DynamicImage::ImageRgba8(rgba_img) + }; + + let mut png: Vec = Vec::new(); + { + let span = + tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered(); + let mut cursor = std::io::Cursor::new(&mut png); + dyn_img + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; + span.record("byte_length", png.len()); + } + + Ok(( + png, + PastedImageInfo { + width: dyn_img.width(), + height: dyn_img.height(), + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Android/Termux does not support arboard; return a clear error. +#[cfg(target_os = "android")] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + +/// Convenience: write to a temp file and return its path + info. +#[cfg(not(target_os = "android"))] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // First attempt: read image from system clipboard via arboard (native paths or image data). + match paste_image_as_png() { + Ok((png, info)) => { + // Create a unique temporary file with a .png suffix to avoid collisions. + let tmp = Builder::new() + .prefix("codex-clipboard-") + .suffix(".png") + .tempfile() + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + std::fs::write(tmp.path(), &png) + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + // Persist the file (so it remains after the handle is dropped) and return its PathBuf. + let (_file, path) = tmp + .keep() + .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; + Ok((path, info)) + } + Err(e) => { + #[cfg(target_os = "linux")] + { + try_wsl_clipboard_fallback(&e).or(Err(e)) + } + #[cfg(not(target_os = "linux"))] + { + Err(e) + } + } + } +} + +/// Attempt WSL fallback for clipboard image paste. +/// +/// If clipboard is unavailable (common under WSL because arboard cannot access +/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the +/// Windows side to write the clipboard image to a temporary file, then return +/// the corresponding WSL path. +#[cfg(target_os = "linux")] +fn try_wsl_clipboard_fallback( + error: &PasteImageError, +) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + use PasteImageError::ClipboardUnavailable; + use PasteImageError::NoImage; + + if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { + return Err(error.clone()); + } + + tracing::debug!("attempting Windows PowerShell clipboard fallback"); + let Some(win_path) = try_dump_windows_clipboard_image() else { + return Err(error.clone()); + }; + + tracing::debug!("powershell produced path: {}", win_path); + let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { + return Err(error.clone()); + }; + + let Ok((w, h)) = image::image_dimensions(&mapped_path) else { + return Err(error.clone()); + }; + + // Return the mapped path directly without copying. + // The file will be read and base64-encoded during serialization. + Ok(( + mapped_path, + PastedImageInfo { + width: w, + height: h, + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Try to call a Windows PowerShell command (several common names) to save the +/// clipboard image to a temporary PNG and return the Windows path to that file. +/// Returns None if no command succeeded or no image was present. +#[cfg(target_os = "linux")] +fn try_dump_windows_clipboard_image() -> Option { + // Powershell script: save image from clipboard to a temp png and print the path. + // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) + // and pwsh (UTF-8 default). + let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; + + for cmd in ["powershell.exe", "pwsh", "powershell"] { + match std::process::Command::new(cmd) + .args(["-NoProfile", "-Command", script]) + .output() + { + // Executing PowerShell command + Ok(output) => { + if output.status.success() { + // Decode as UTF-8 (forced by the script above). + let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !win_path.is_empty() { + tracing::debug!("{} saved clipboard image to {}", cmd, win_path); + return Some(win_path); + } + } else { + tracing::debug!("{} returned non-zero status", cmd); + } + } + Err(err) => { + tracing::debug!("{} not executable: {}", cmd, err); + } + } + } + None +} + +#[cfg(target_os = "android")] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // Keep error consistent with paste_image_as_png. + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + +/// Normalize pasted text that may represent a filesystem path. +/// +/// Supports: +/// - `file://` URLs (converted to local paths) +/// - Windows/UNC paths +/// - shell-escaped single paths (via `shlex`) +pub fn normalize_pasted_path(pasted: &str) -> Option { + let pasted = pasted.trim(); + + // file:// URL → filesystem path + if let Ok(url) = url::Url::parse(pasted) + && url.scheme() == "file" + { + return url.to_file_path().ok(); + } + + // TODO: We'll improve the implementation/unit tests over time, as appropriate. + // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e + // + // Detect unquoted Windows paths and bypass POSIX shlex which + // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). + // Also handles UNC paths (\\server\share\path). + let looks_like_windows_path = { + // Drive letter path: C:\ or C:/ + let drive = pasted + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && pasted.get(1..2) == Some(":") + && pasted + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = pasted.starts_with("\\\\"); + drive || unc + }; + if looks_like_windows_path { + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(pasted) + { + return Some(converted); + } + } + return Some(PathBuf::from(pasted)); + } + + // shell-escaped single path → unescaped + let parts: Vec = shlex::Shlex::new(pasted).collect(); + if parts.len() == 1 { + return parts.into_iter().next().map(PathBuf::from); + } + + None +} + +#[cfg(target_os = "linux")] +pub(crate) fn is_probably_wsl() -> bool { + // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). + if let Ok(version) = std::fs::read_to_string("/proc/version") { + let version_lower = version.to_lowercase(); + if version_lower.contains("microsoft") || version_lower.contains("wsl") { + return true; + } + } + + // Fallback: Check WSL environment variables. This handles edge cases like + // custom Linux kernels installed in WSL where /proc/version may not contain + // "microsoft" or "WSL". + std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() +} + +#[cfg(target_os = "linux")] +fn convert_windows_path_to_wsl(input: &str) -> Option { + if input.starts_with("\\\\") { + return None; + } + + let drive_letter = input.chars().next()?.to_ascii_lowercase(); + if !drive_letter.is_ascii_lowercase() { + return None; + } + + if input.get(1..2) != Some(":") { + return None; + } + + let mut result = PathBuf::from(format!("/mnt/{drive_letter}")); + for component in input + .get(2..)? + .trim_start_matches(['\\', '/']) + .split(['\\', '/']) + .filter(|component| !component.is_empty()) + { + result.push(component); + } + + Some(result) +} + +/// Infer an image format for the provided path based on its extension. +pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => EncodedImageFormat::Png, + Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg, + _ => EncodedImageFormat::Other, + } +} + +#[cfg(test)] +mod pasted_paths_tests { + use super::*; + + #[cfg(not(windows))] + #[test] + fn normalize_file_url() { + let input = "file:///tmp/example.png"; + let result = normalize_pasted_path(input).expect("should parse file URL"); + assert_eq!(result, PathBuf::from("/tmp/example.png")); + } + + #[test] + fn normalize_file_url_windows() { + let input = r"C:\Temp\example.png"; + let result = normalize_pasted_path(input).expect("should parse file URL"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + converted + } else { + PathBuf::from(r"C:\Temp\example.png") + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(r"C:\Temp\example.png"); + assert_eq!(result, expected); + } + + #[test] + fn normalize_shell_escaped_single_path() { + let input = "/home/user/My\\ File.png"; + let result = normalize_pasted_path(input).expect("should unescape shell-escaped path"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_simple_quoted_path_fallback() { + let input = "\"/home/user/My File.png\""; + let result = normalize_pasted_path(input).expect("should trim simple quotes"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_single_quoted_unix_path() { + let input = "'/home/user/My File.png'"; + let result = normalize_pasted_path(input).expect("should trim single quotes via shlex"); + assert_eq!(result, PathBuf::from("/home/user/My File.png")); + } + + #[test] + fn normalize_multiple_tokens_returns_none() { + // Two tokens after shell splitting → not a single path + let input = "/home/user/a\\ b.png /home/user/c.png"; + let result = normalize_pasted_path(input); + assert!(result.is_none()); + } + + #[test] + fn pasted_image_format_png_jpeg_unknown() { + assert_eq!( + pasted_image_format(Path::new("/a/b/c.PNG")), + EncodedImageFormat::Png + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.jpg")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.JPEG")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c")), + EncodedImageFormat::Other + ); + assert_eq!( + pasted_image_format(Path::new("/a/b/c.webp")), + EncodedImageFormat::Other + ); + } + + #[test] + fn normalize_single_quoted_windows_path() { + let input = r"'C:\\Users\\Alice\\My File.jpeg'"; + let result = + normalize_pasted_path(input).expect("should trim single quotes on windows path"); + assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + } + + #[test] + fn normalize_unquoted_windows_path_with_spaces() { + let input = r"C:\\Users\\Alice\\My Pictures\\example image.png"; + let result = normalize_pasted_path(input).expect("should accept unquoted windows path"); + #[cfg(target_os = "linux")] + let expected = if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(input) + { + converted + } else { + PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png") + }; + #[cfg(not(target_os = "linux"))] + let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png"); + assert_eq!(result, expected); + } + + #[test] + fn normalize_unc_windows_path() { + let input = r"\\\\server\\share\\folder\\file.jpg"; + let result = normalize_pasted_path(input).expect("should accept UNC windows path"); + assert_eq!( + result, + PathBuf::from(r"\\\\server\\share\\folder\\file.jpg") + ); + } + + #[test] + fn pasted_image_format_with_windows_style_paths() { + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")), + EncodedImageFormat::Png + ); + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")), + EncodedImageFormat::Jpeg + ); + assert_eq!( + pasted_image_format(Path::new(r"C:\\a\\b\\noext")), + EncodedImageFormat::Other + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn normalize_windows_path_in_wsl() { + // This test only runs on actual WSL systems + if !is_probably_wsl() { + // Skip test if not on WSL + return; + } + let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; + let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); + assert_eq!( + result, + PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png") + ); + } +} diff --git a/codex-rs/tui2/src/color.rs b/codex-rs/tui2/src/color.rs new file mode 100644 index 0000000000..f5121a1f6c --- /dev/null +++ b/codex-rs/tui2/src/color.rs @@ -0,0 +1,75 @@ +pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool { + let (r, g, b) = bg; + let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + y > 128.0 +} + +pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { + let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8; + let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8; + let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8; + (r, g, b) +} + +/// Returns the perceptual color distance between two RGB colors. +/// Uses the CIE76 formula (Euclidean distance in Lab space approximation). +pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { + // Convert sRGB to linear RGB + fn srgb_to_linear(c: u8) -> f32 { + let c = c as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + + // Convert RGB to XYZ + fn rgb_to_xyz(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = srgb_to_linear(r); + let g = srgb_to_linear(g); + let b = srgb_to_linear(b); + + let x = r * 0.4124 + g * 0.3576 + b * 0.1805; + let y = r * 0.2126 + g * 0.7152 + b * 0.0722; + let z = r * 0.0193 + g * 0.1192 + b * 0.9505; + (x, y, z) + } + + // Convert XYZ to Lab + fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) { + // D65 reference white + let xr = x / 0.95047; + let yr = y / 1.00000; + let zr = z / 1.08883; + + fn f(t: f32) -> f32 { + if t > 0.008856 { + t.powf(1.0 / 3.0) + } else { + 7.787 * t + 16.0 / 116.0 + } + } + + let fx = f(xr); + let fy = f(yr); + let fz = f(zr); + + let l = 116.0 * fy - 16.0; + let a = 500.0 * (fx - fy); + let b = 200.0 * (fy - fz); + (l, a, b) + } + + let (x1, y1, z1) = rgb_to_xyz(a.0, a.1, a.2); + let (x2, y2, z2) = rgb_to_xyz(b.0, b.1, b.2); + + let (l1, a1, b1) = xyz_to_lab(x1, y1, z1); + let (l2, a2, b2) = xyz_to_lab(x2, y2, z2); + + let dl = l1 - l2; + let da = a1 - a2; + let db = b1 - b2; + + (dl * dl + da * da + db * db).sqrt() +} diff --git a/codex-rs/tui2/src/custom_terminal.rs b/codex-rs/tui2/src/custom_terminal.rs new file mode 100644 index 0000000000..46d16a83f0 --- /dev/null +++ b/codex-rs/tui2/src/custom_terminal.rs @@ -0,0 +1,645 @@ +// This is derived from `ratatui::Terminal`, which is licensed under the following terms: +// +// The MIT License (MIT) +// Copyright (c) 2016-2022 Florian Dehau +// Copyright (c) 2023-2025 The Ratatui Developers +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +use std::io; +use std::io::Write; + +use crossterm::cursor::MoveTo; +use crossterm::queue; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use crossterm::terminal::Clear; +use derive_more::IsVariant; +use ratatui::backend::Backend; +use ratatui::backend::ClearType; +use ratatui::buffer::Buffer; +use ratatui::layout::Position; +use ratatui::layout::Rect; +use ratatui::layout::Size; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::widgets::WidgetRef; + +#[derive(Debug, Hash)] +pub struct Frame<'a> { + /// Where should the cursor be after drawing this frame? + /// + /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, + /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. + pub(crate) cursor_position: Option, + + /// The area of the viewport + pub(crate) viewport_area: Rect, + + /// The buffer that is used to draw the current frame + pub(crate) buffer: &'a mut Buffer, +} + +impl Frame<'_> { + /// The area of the current frame + /// + /// This is guaranteed not to change during rendering, so may be called multiple times. + /// + /// If your app listens for a resize event from the backend, it should ignore the values from + /// the event for any calculations that are used to render the current frame and use this value + /// instead as this is the area of the buffer that is used to render the current frame. + pub const fn area(&self) -> Rect { + self.viewport_area + } + + /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. + /// + /// Usually the area argument is the size of the current frame or a sub-area of the current + /// frame (which can be obtained using [`Layout`] to split the total area). + #[allow(clippy::needless_pass_by_value)] + pub fn render_widget_ref(&mut self, widget: W, area: Rect) { + widget.render_ref(area, self.buffer); + } + + /// After drawing this frame, make the cursor visible and put it at the specified (x, y) + /// coordinates. If this method is not called, the cursor will be hidden. + /// + /// Note that this will interfere with calls to [`Terminal::hide_cursor`], + /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and + /// stick with it. + /// + /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor + /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor + /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position + pub fn set_cursor_position>(&mut self, position: P) { + self.cursor_position = Some(position.into()); + } + + /// Gets the buffer that this `Frame` draws into as a mutable reference. + pub fn buffer_mut(&mut self) -> &mut Buffer { + self.buffer + } +} + +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct Terminal +where + B: Backend + Write, +{ + /// The backend used to interface with the terminal + backend: B, + /// Holds the results of the current and previous draw calls. The two are compared at the end + /// of each draw pass to output the necessary updates to the terminal + buffers: [Buffer; 2], + /// Index of the current buffer in the previous array + current: usize, + /// Whether the cursor is currently hidden + pub hidden_cursor: bool, + /// Area of the viewport + pub viewport_area: Rect, + /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. + pub last_known_screen_size: Size, + /// Last known position of the cursor. Used to find the new area when the viewport is inlined + /// and the terminal resized. + pub last_known_cursor_pos: Position, +} + +impl Drop for Terminal +where + B: Backend, + B: Write, +{ + #[allow(clippy::print_stderr)] + fn drop(&mut self) { + // Attempt to restore the cursor state + if self.hidden_cursor + && let Err(err) = self.show_cursor() + { + eprintln!("Failed to show the cursor: {err}"); + } + } +} + +impl Terminal +where + B: Backend, + B: Write, +{ + /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. + pub fn with_options(mut backend: B) -> io::Result { + let screen_size = backend.size()?; + let cursor_pos = backend.get_cursor_position()?; + Ok(Self { + backend, + buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], + current: 0, + hidden_cursor: false, + viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_pos, + }) + } + + /// Get a Frame object which provides a consistent view into the terminal state for rendering. + pub fn get_frame(&mut self) -> Frame<'_> { + Frame { + cursor_position: None, + viewport_area: self.viewport_area, + buffer: self.current_buffer_mut(), + } + } + + /// Gets the current buffer as a reference. + fn current_buffer(&self) -> &Buffer { + &self.buffers[self.current] + } + + /// Gets the current buffer as a mutable reference. + fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + /// Gets the previous buffer as a reference. + fn previous_buffer(&self) -> &Buffer { + &self.buffers[1 - self.current] + } + + /// Gets the previous buffer as a mutable reference. + fn previous_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[1 - self.current] + } + + /// Gets the backend + pub const fn backend(&self) -> &B { + &self.backend + } + + /// Gets the backend as a mutable reference + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + /// Obtains a difference between the previous and the current buffer and passes it to the + /// current backend for drawing. + pub fn flush(&mut self) -> io::Result<()> { + let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); + let last_put_command = updates.iter().rfind(|command| command.is_put()); + if let Some(&DrawCommand::Put { x, y, .. }) = last_put_command { + self.last_known_cursor_pos = Position { x, y }; + } + draw(&mut self.backend, updates.into_iter()) + } + + /// Updates the Terminal so that internal buffers match the requested area. + /// + /// Requested area will be saved to remain consistent when rendering. This leads to a full clear + /// of the screen. + pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { + self.last_known_screen_size = screen_size; + Ok(()) + } + + /// Sets the viewport area. + pub fn set_viewport_area(&mut self, area: Rect) { + self.current_buffer_mut().resize(area); + self.previous_buffer_mut().resize(area); + self.viewport_area = area; + } + + /// Queries the backend for size and resizes if it doesn't match the previous size. + pub fn autoresize(&mut self) -> io::Result<()> { + let screen_size = self.size()?; + if screen_size != self.last_known_screen_size { + self.resize(screen_size)?; + } + Ok(()) + } + + /// Draws a single frame to the terminal. + /// + /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. + /// + /// If the render callback passed to this method can fail, use [`try_draw`] instead. + /// + /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`try_draw`]: Terminal::try_draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render callback does not fully render the frame, the terminal will not be + /// in a consistent state. + pub fn draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame), + { + self.try_draw(|frame| { + render_callback(frame); + io::Result::Ok(()) + }) + } + + /// Tries to draw a single frame to the terminal. + /// + /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise + /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. + /// + /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or + /// closure that returns a `Result` instead of nothing. + /// + /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the + /// terminal. These methods are the main entry points for drawing to the terminal. + /// + /// [`draw`]: Terminal::draw + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// + /// The render callback passed to `try_draw` can return any [`Result`] with an error type that + /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible + /// to use the `?` operator to propagate errors that occur during rendering. If the render + /// callback returns an error, the error will be returned from `try_draw` as an + /// [`std::io::Error`] and the terminal will not be updated. + /// + /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing + /// purposes, but it is often not used in regular applicationss. + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render function does not fully render the frame, the terminal will not be + /// in a consistent state. + pub fn try_draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame) -> Result<(), E>, + E: Into, + { + // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets + // and the terminal (if growing), which may OOB. + self.autoresize()?; + + let mut frame = self.get_frame(); + + render_callback(&mut frame).map_err(Into::into)?; + + // We can't change the cursor position right away because we have to flush the frame to + // stdout first. But we also can't keep the frame around, since it holds a &mut to + // Buffer. Thus, we're taking the important data out of the Frame and dropping it. + let cursor_position = frame.cursor_position; + + // Draw to stdout + self.flush()?; + + match cursor_position { + None => self.hide_cursor()?, + Some(position) => { + self.show_cursor()?; + self.set_cursor_position(position)?; + } + } + + self.swap_buffers(); + + Backend::flush(&mut self.backend)?; + + Ok(()) + } + + /// Hides the cursor. + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + /// Shows the cursor. + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + /// Gets the current cursor position. + /// + /// This is the position of the cursor after the last draw call. + #[allow(dead_code)] + pub fn get_cursor_position(&mut self) -> io::Result { + self.backend.get_cursor_position() + } + + /// Sets the cursor position. + pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.backend.set_cursor_position(position)?; + self.last_known_cursor_pos = position; + Ok(()) + } + + /// Clear the terminal and force a full redraw on the next draw call. + pub fn clear(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + self.backend.clear_region(ClearType::AfterCursor)?; + // Reset the back buffer to make sure the next update will redraw everything. + self.previous_buffer_mut().reset(); + Ok(()) + } + + /// Clears the inactive buffer and swaps it with the current buffer + pub fn swap_buffers(&mut self) { + self.previous_buffer_mut().reset(); + self.current = 1 - self.current; + } + + /// Queries the real size of the backend. + pub fn size(&self) -> io::Result { + self.backend.size() + } +} + +use ratatui::buffer::Cell; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, IsVariant)] +enum DrawCommand { + Put { x: u16, y: u16, cell: Cell }, + ClearToEnd { x: u16, y: u16, bg: Color }, +} + +fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { + let previous_buffer = &a.content; + let next_buffer = &b.content; + + let mut updates = vec![]; + let mut last_nonblank_columns = vec![0; a.area.height as usize]; + for y in 0..a.area.height { + let row_start = y as usize * a.area.width as usize; + let row_end = row_start + a.area.width as usize; + let row = &next_buffer[row_start..row_end]; + let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); + + // Scan the row to find the rightmost column that still matters: any non-space glyph, + // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. + // Multi-width glyphs extend that region through their full displayed width. + // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win + // versus emitting multiple space Put commands. + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = cell.symbol().width(); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + (width.saturating_sub(1)); + } + column += width.max(1); // treat zero-width symbols as width 1 + } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + + last_nonblank_columns[y as usize] = last_nonblank_column as u16; + } + + // Cells invalidated by drawing/replacing preceding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceding multi-width characters taking + // their place (the skipped cells should be blank anyway), or due to per-cell-skipping: + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { + let (x, y) = a.pos_of(i); + let row = i / a.area.width as usize; + if x <= last_nonblank_columns[row] { + updates.push(DrawCommand::Put { + x, + y, + cell: next_buffer[i].clone(), + }); + } + } + + to_skip = current.symbol().width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates +} + +fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> +where + I: Iterator, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option = None; + for command in commands { + let (x, y) = match command { + DrawCommand::Put { x, y, .. } => (x, y), + DrawCommand::ClearToEnd { x, y, .. } => (x, y), + }; + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { + queue!(writer, MoveTo(x, y))?; + } + last_pos = Some(Position { x, y }); + match command { + DrawCommand::Put { cell, .. } => { + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(writer)?; + modifier = cell.modifier; + } + if cell.fg != fg || cell.bg != bg { + queue!( + writer, + SetColors(Colors::new(cell.fg.into(), cell.bg.into())) + )?; + fg = cell.fg; + bg = cell.bg; + } + + queue!(writer, Print(cell.symbol()))?; + } + DrawCommand::ClearToEnd { bg: clear_bg, .. } => { + queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; + modifier = Modifier::empty(); + queue!(writer, SetBackgroundColor(clear_bg.into()))?; + bg = clear_bg; + queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; + } + } + } + + queue!( + writer, + SetForegroundColor(crossterm::style::Color::Reset), + SetBackgroundColor(crossterm::style::Color::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + )?; + + Ok(()) +} + +/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier` +/// values. This is useful when updating the terminal display, as it allows for more +/// efficient updates by only sending the necessary changes. +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue(self, w: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use ratatui::style::Style; + + #[test] + fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { + let area = Rect::new(0, 0, 3, 2); + let previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + next.cell_mut((2, 0)) + .expect("cell should exist") + .set_symbol("X"); + + let commands = diff_buffers(&previous, &next); + + let clear_count = commands + .iter() + .filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0)) + .count(); + assert_eq!( + 0, clear_count, + "expected diff_buffers not to emit ClearToEnd; commands: {commands:?}", + ); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })), + "expected diff_buffers to update the final cell; commands: {commands:?}", + ); + } + + #[test] + fn diff_buffers_clear_to_end_starts_after_wide_char() { + let area = Rect::new(0, 0, 10, 1); + let mut previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + previous.set_string(0, 0, "中文", Style::default()); + next.set_string(0, 0, "中", Style::default()); + + let commands = diff_buffers(&previous, &next); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), + "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" + ); + } +} diff --git a/codex-rs/tui2/src/diff_render.rs b/codex-rs/tui2/src/diff_render.rs new file mode 100644 index 0000000000..24c5be597b --- /dev/null +++ b/codex-rs/tui2/src/diff_render.rs @@ -0,0 +1,673 @@ +use diffy::Hunk; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line as RtLine; +use ratatui::text::Span as RtSpan; +use ratatui::widgets::Paragraph; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use crate::exec_command::relativize_to_home; +use crate::render::Insets; +use crate::render::line_utils::prefix_lines; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; +use codex_core::git_info::get_git_repo_root; +use codex_core::protocol::FileChange; + +// Internal representation for diff line rendering +enum DiffLineType { + Insert, + Delete, + Context, +} + +pub struct DiffSummary { + changes: HashMap, + cwd: PathBuf, +} + +impl DiffSummary { + pub fn new(changes: HashMap, cwd: PathBuf) -> Self { + Self { changes, cwd } + } +} + +impl Renderable for FileChange { + fn render(&self, area: Rect, buf: &mut Buffer) { + let mut lines = vec![]; + render_change(self, &mut lines, area.width as usize); + Paragraph::new(lines).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let mut lines = vec![]; + render_change(self, &mut lines, width as usize); + lines.len() as u16 + } +} + +impl From for Box { + fn from(val: DiffSummary) -> Self { + let mut rows: Vec> = vec![]; + + for (i, row) in collect_rows(&val.changes).into_iter().enumerate() { + if i > 0 { + rows.push(Box::new(RtLine::from(""))); + } + let mut path = RtLine::from(display_path_for(&row.path, &val.cwd)); + path.push_span(" "); + path.extend(render_line_count_summary(row.added, row.removed)); + rows.push(Box::new(path)); + rows.push(Box::new(RtLine::from(""))); + rows.push(Box::new(InsetRenderable::new( + Box::new(row.change) as Box, + Insets::tlbr(0, 2, 0, 0), + ))); + } + + Box::new(ColumnRenderable::with(rows)) + } +} + +pub(crate) fn create_diff_summary( + changes: &HashMap, + cwd: &Path, + wrap_cols: usize, +) -> Vec> { + let rows = collect_rows(changes); + render_changes_block(rows, wrap_cols, cwd) +} + +// Shared row for per-file presentation +#[derive(Clone)] +struct Row { + #[allow(dead_code)] + path: PathBuf, + move_path: Option, + added: usize, + removed: usize, + change: FileChange, +} + +fn collect_rows(changes: &HashMap) -> Vec { + let mut rows: Vec = Vec::new(); + for (path, change) in changes.iter() { + let (added, removed) = match change { + FileChange::Add { content } => (content.lines().count(), 0), + FileChange::Delete { content } => (0, content.lines().count()), + FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff), + }; + let move_path = match change { + FileChange::Update { + move_path: Some(new), + .. + } => Some(new.clone()), + _ => None, + }; + rows.push(Row { + path: path.clone(), + move_path, + added, + removed, + change: change.clone(), + }); + } + rows.sort_by_key(|r| r.path.clone()); + rows +} + +fn render_line_count_summary(added: usize, removed: usize) -> Vec> { + let mut spans = Vec::new(); + spans.push("(".into()); + spans.push(format!("+{added}").green()); + spans.push(" ".into()); + spans.push(format!("-{removed}").red()); + spans.push(")".into()); + spans +} + +fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> { + let mut out: Vec> = Vec::new(); + + let render_path = |row: &Row| -> Vec> { + let mut spans = Vec::new(); + spans.push(display_path_for(&row.path, cwd).into()); + if let Some(move_path) = &row.move_path { + spans.push(format!(" → {}", display_path_for(move_path, cwd)).into()); + } + spans + }; + + // Header + let total_added: usize = rows.iter().map(|r| r.added).sum(); + let total_removed: usize = rows.iter().map(|r| r.removed).sum(); + let file_count = rows.len(); + let noun = if file_count == 1 { "file" } else { "files" }; + let mut header_spans: Vec> = vec!["• ".dim()]; + if let [row] = &rows[..] { + let verb = match &row.change { + FileChange::Add { .. } => "Added", + FileChange::Delete { .. } => "Deleted", + _ => "Edited", + }; + header_spans.push(verb.bold()); + header_spans.push(" ".into()); + header_spans.extend(render_path(row)); + header_spans.push(" ".into()); + header_spans.extend(render_line_count_summary(row.added, row.removed)); + } else { + header_spans.push("Edited".bold()); + header_spans.push(format!(" {file_count} {noun} ").into()); + header_spans.extend(render_line_count_summary(total_added, total_removed)); + } + out.push(RtLine::from(header_spans)); + + for (idx, r) in rows.into_iter().enumerate() { + // Insert a blank separator between file chunks (except before the first) + if idx > 0 { + out.push("".into()); + } + // File header line (skip when single-file header already shows the name) + let skip_file_header = file_count == 1; + if !skip_file_header { + let mut header: Vec> = Vec::new(); + header.push(" └ ".dim()); + header.extend(render_path(&r)); + header.push(" ".into()); + header.extend(render_line_count_summary(r.added, r.removed)); + out.push(RtLine::from(header)); + } + + let mut lines = vec![]; + render_change(&r.change, &mut lines, wrap_cols - 4); + out.extend(prefix_lines(lines, " ".into(), " ".into())); + } + + out +} + +fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { + match change { + FileChange::Add { content } => { + let line_number_width = line_number_width(content.lines().count()); + for (i, raw) in content.lines().enumerate() { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + )); + } + } + FileChange::Delete { content } => { + let line_number_width = line_number_width(content.lines().count()); + for (i, raw) in content.lines().enumerate() { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + )); + } + } + FileChange::Update { unified_diff, .. } => { + if let Ok(patch) = diffy::Patch::from_str(unified_diff) { + let mut max_line_number = 0; + for h in patch.hunks() { + let mut old_ln = h.old_range().start(); + let mut new_ln = h.new_range().start(); + for l in h.lines() { + match l { + diffy::Line::Insert(_) => { + max_line_number = max_line_number.max(new_ln); + new_ln += 1; + } + diffy::Line::Delete(_) => { + max_line_number = max_line_number.max(old_ln); + old_ln += 1; + } + diffy::Line::Context(_) => { + max_line_number = max_line_number.max(new_ln); + old_ln += 1; + new_ln += 1; + } + } + } + } + let line_number_width = line_number_width(max_line_number); + let mut is_first_hunk = true; + for h in patch.hunks() { + if !is_first_hunk { + let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); + let spacer_span = RtSpan::styled(spacer, style_gutter()); + out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); + } + is_first_hunk = false; + + let mut old_ln = h.old_range().start(); + let mut new_ln = h.new_range().start(); + for l in h.lines() { + match l { + diffy::Line::Insert(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + )); + new_ln += 1; + } + diffy::Line::Delete(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + )); + old_ln += 1; + } + diffy::Line::Context(text) => { + let s = text.trim_end_matches('\n'); + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + )); + old_ln += 1; + new_ln += 1; + } + } + } + } + } + } + } +} + +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { + (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, + _ => false, + }; + let chosen = if path_in_same_repo { + pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) + } else { + relativize_to_home(path) + .map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()])) + .unwrap_or_else(|| path.to_path_buf()) + }; + chosen.display().to_string() +} + +fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { + if let Ok(patch) = diffy::Patch::from_str(diff) { + patch + .hunks() + .iter() + .flat_map(Hunk::lines) + .fold((0, 0), |(a, d), l| match l { + diffy::Line::Insert(_) => (a + 1, d), + diffy::Line::Delete(_) => (a, d + 1), + diffy::Line::Context(_) => (a, d), + }) + } else { + // For unparsable diffs, return 0 for both counts. + (0, 0) + } +} + +fn push_wrapped_diff_line( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, +) -> Vec> { + let ln_str = line_number.to_string(); + let mut remaining_text: &str = text; + + // Reserve a fixed number of spaces (equal to the widest line number plus a + // trailing spacer) so the sign column stays aligned across the diff block. + let gutter_width = line_number_width.max(1); + let prefix_cols = gutter_width + 1; + + let mut first = true; + let (sign_char, line_style) = match kind { + DiffLineType::Insert => ('+', style_add()), + DiffLineType::Delete => ('-', style_del()), + DiffLineType::Context => (' ', style_context()), + }; + let mut lines: Vec> = Vec::new(); + + loop { + // Fit the content for the current terminal row: + // compute how many columns are available after the prefix, then split + // at a UTF-8 character boundary so this row's chunk fits exactly. + let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); + let split_at_byte_index = remaining_text + .char_indices() + .nth(available_content_cols) + .map(|(i, _)| i) + .unwrap_or_else(|| remaining_text.len()); + let (chunk, rest) = remaining_text.split_at(split_at_byte_index); + remaining_text = rest; + + if first { + // Build gutter (right-aligned line number plus spacer) as a dimmed span + let gutter = format!("{ln_str:>gutter_width$} "); + // Content with a sign ('+'/'-'/' ') styled per diff kind + let content = format!("{sign_char}{chunk}"); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(content, line_style), + ])); + first = false; + } else { + // Continuation lines keep a space for the sign column so content aligns + let gutter = format!("{:gutter_width$} ", ""); + lines.push(RtLine::from(vec![ + RtSpan::styled(gutter, style_gutter()), + RtSpan::styled(chunk.to_string(), line_style), + ])); + } + if remaining_text.is_empty() { + break; + } + } + lines +} + +fn line_number_width(max_line_number: usize) -> usize { + if max_line_number == 0 { + 1 + } else { + max_line_number.to_string().len() + } +} + +fn style_gutter() -> Style { + Style::default().add_modifier(Modifier::DIM) +} + +fn style_context() -> Style { + Style::default() +} + +fn style_add() -> Style { + Style::default().fg(Color::Green) +} + +fn style_del() -> Style { + Style::default().fg(Color::Red) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::text::Text; + use ratatui::widgets::Paragraph; + use ratatui::widgets::WidgetRef; + use ratatui::widgets::Wrap; + fn diff_summary_for_tests(changes: &HashMap) -> Vec> { + create_diff_summary(changes, &PathBuf::from("/"), 80) + } + + fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| { + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .render_ref(f.area(), f.buffer_mut()) + }) + .expect("draw"); + assert_snapshot!(name, terminal.backend()); + } + + fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { + // Convert Lines to plain text rows and trim trailing spaces so it's + // easier to validate indentation visually in snapshots. + let text = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .map(|s| s.trim_end().to_string()) + .collect::>() + .join("\n"); + assert_snapshot!(name, text); + } + + #[test] + fn ui_snapshot_wrap_behavior_insert() { + // Narrow width to force wrapping within our diff line rendering + let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; + + // Call the wrapping function directly so we can precisely control the width + let lines = + push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1)); + + // Render into a small terminal to capture the visual layout + snapshot_lines("wrap_behavior_insert", lines, 90, 8); + } + + #[test] + fn ui_snapshot_apply_update_block() { + let mut changes: HashMap = HashMap::new(); + let original = "line one\nline two\nline three\n"; + let modified = "line one\nline two changed\nline three\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_update_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_with_rename_block() { + let mut changes: HashMap = HashMap::new(); + let original = "A\nB\nC\n"; + let modified = "A\nB changed\nC\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + changes.insert( + PathBuf::from("old_name.rs"), + FileChange::Update { + unified_diff: patch, + move_path: Some(PathBuf::from("new_name.rs")), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_update_with_rename_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_multiple_files_block() { + // Two files: one update and one add, to exercise combined header and per-file rows + let mut changes: HashMap = HashMap::new(); + + // File a.txt: single-line replacement (one delete, one insert) + let patch_a = diffy::create_patch("one\n", "one changed\n").to_string(); + changes.insert( + PathBuf::from("a.txt"), + FileChange::Update { + unified_diff: patch_a, + move_path: None, + }, + ); + + // File b.txt: newly added with one line + changes.insert( + PathBuf::from("b.txt"), + FileChange::Add { + content: "new\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_multiple_files_block", lines, 80, 14); + } + + #[test] + fn ui_snapshot_apply_add_block() { + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("new_file.txt"), + FileChange::Add { + content: "alpha\nbeta\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + snapshot_lines("apply_add_block", lines, 80, 10); + } + + #[test] + fn ui_snapshot_apply_delete_block() { + // Write a temporary file so the delete renderer can read original content + let tmp_path = PathBuf::from("tmp_delete_example.txt"); + std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file"); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + tmp_path.clone(), + FileChange::Delete { + content: "first\nsecond\nthird\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + // Cleanup best-effort; rendering has already read the file + let _ = std::fs::remove_file(&tmp_path); + + snapshot_lines("apply_delete_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines() { + // Create a patch with a long modified line to force wrapping + let original = "line 1\nshort\nline 3\n"; + let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("long_example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72); + + // Render with backend width wider than wrap width to avoid Paragraph auto-wrap. + snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines_text() { + // This mirrors the desired layout example: sign only on first inserted line, + // subsequent wrapped pieces start aligned under the line number gutter. + let original = "1\n2\n3\n4\n"; + let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("wrap_demo.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); + snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); + } + + #[test] + fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { + let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); + let modified = (1..=110) + .map(|i| { + if i == 100 { + format!("line {i} changed\n") + } else { + format!("line {i}\n") + } + }) + .collect::(); + let patch = diffy::create_patch(&original, &modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("hundreds.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); + snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines); + } + + #[test] + fn ui_snapshot_apply_update_block_relativizes_path() { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let abs_old = cwd.join("abs_old.rs"); + let abs_new = cwd.join("abs_new.rs"); + + let original = "X\nY\n"; + let modified = "X changed\nY\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + abs_old, + FileChange::Update { + unified_diff: patch, + move_path: Some(abs_new), + }, + ); + + let lines = create_diff_summary(&changes, &cwd, 80); + + snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); + } +} diff --git a/codex-rs/tui2/src/exec_cell/mod.rs b/codex-rs/tui2/src/exec_cell/mod.rs new file mode 100644 index 0000000000..906091113e --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/mod.rs @@ -0,0 +1,12 @@ +mod model; +mod render; + +pub(crate) use model::CommandOutput; +#[cfg(test)] +pub(crate) use model::ExecCall; +pub(crate) use model::ExecCell; +pub(crate) use render::OutputLinesParams; +pub(crate) use render::TOOL_CALL_MAX_LINES; +pub(crate) use render::new_active_exec_command; +pub(crate) use render::output_lines; +pub(crate) use render::spinner; diff --git a/codex-rs/tui2/src/exec_cell/model.rs b/codex-rs/tui2/src/exec_cell/model.rs new file mode 100644 index 0000000000..76316968c6 --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/model.rs @@ -0,0 +1,150 @@ +use std::time::Duration; +use std::time::Instant; + +use codex_core::protocol::ExecCommandSource; +use codex_protocol::parse_command::ParsedCommand; + +#[derive(Clone, Debug, Default)] +pub(crate) struct CommandOutput { + pub(crate) exit_code: i32, + /// The aggregated stderr + stdout interleaved. + pub(crate) aggregated_output: String, + /// The formatted output of the command, as seen by the model. + pub(crate) formatted_output: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExecCall { + pub(crate) call_id: String, + pub(crate) command: Vec, + pub(crate) parsed: Vec, + pub(crate) output: Option, + pub(crate) source: ExecCommandSource, + pub(crate) start_time: Option, + pub(crate) duration: Option, + pub(crate) interaction_input: Option, +} + +#[derive(Debug)] +pub(crate) struct ExecCell { + pub(crate) calls: Vec, + animations_enabled: bool, +} + +impl ExecCell { + pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self { + Self { + calls: vec![call], + animations_enabled, + } + } + + pub(crate) fn with_added_call( + &self, + call_id: String, + command: Vec, + parsed: Vec, + source: ExecCommandSource, + interaction_input: Option, + ) -> Option { + let call = ExecCall { + call_id, + command, + parsed, + output: None, + source, + start_time: Some(Instant::now()), + duration: None, + interaction_input, + }; + if self.is_exploring_cell() && Self::is_exploring_call(&call) { + Some(Self { + calls: [self.calls.clone(), vec![call]].concat(), + animations_enabled: self.animations_enabled, + }) + } else { + None + } + } + + pub(crate) fn complete_call( + &mut self, + call_id: &str, + output: CommandOutput, + duration: Duration, + ) { + if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { + call.output = Some(output); + call.duration = Some(duration); + call.start_time = None; + } + } + + pub(crate) fn should_flush(&self) -> bool { + !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) + } + + pub(crate) fn mark_failed(&mut self) { + for call in self.calls.iter_mut() { + if call.output.is_none() { + let elapsed = call + .start_time + .map(|st| st.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + call.start_time = None; + call.duration = Some(elapsed); + call.output = Some(CommandOutput { + exit_code: 1, + formatted_output: String::new(), + aggregated_output: String::new(), + }); + } + } + } + + pub(crate) fn is_exploring_cell(&self) -> bool { + self.calls.iter().all(Self::is_exploring_call) + } + + pub(crate) fn is_active(&self) -> bool { + self.calls.iter().any(|c| c.output.is_none()) + } + + pub(crate) fn active_start_time(&self) -> Option { + self.calls + .iter() + .find(|c| c.output.is_none()) + .and_then(|c| c.start_time) + } + + pub(crate) fn animations_enabled(&self) -> bool { + self.animations_enabled + } + + pub(crate) fn iter_calls(&self) -> impl Iterator { + self.calls.iter() + } + + pub(super) fn is_exploring_call(call: &ExecCall) -> bool { + !matches!(call.source, ExecCommandSource::UserShell) + && !call.parsed.is_empty() + && call.parsed.iter().all(|p| { + matches!( + p, + ParsedCommand::Read { .. } + | ParsedCommand::ListFiles { .. } + | ParsedCommand::Search { .. } + ) + }) + } +} + +impl ExecCall { + pub(crate) fn is_user_shell_command(&self) -> bool { + matches!(self.source, ExecCommandSource::UserShell) + } + + pub(crate) fn is_unified_exec_interaction(&self) -> bool { + matches!(self.source, ExecCommandSource::UnifiedExecInteraction) + } +} diff --git a/codex-rs/tui2/src/exec_cell/render.rs b/codex-rs/tui2/src/exec_cell/render.rs new file mode 100644 index 0000000000..6517bcf470 --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/render.rs @@ -0,0 +1,705 @@ +use std::time::Instant; + +use super::model::CommandOutput; +use super::model::ExecCall; +use super::model::ExecCell; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell::HistoryCell; +use crate::render::highlight::highlight_bash_to_lines; +use crate::render::line_utils::prefix_lines; +use crate::render::line_utils::push_owned_lines; +use crate::shimmer::shimmer_spans; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines; +use codex_ansi_escape::ansi_escape_line; +use codex_common::elapsed::format_duration; +use codex_core::bash::extract_bash_command; +use codex_core::protocol::ExecCommandSource; +use codex_protocol::parse_command::ParsedCommand; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Stylize; +use textwrap::WordSplitter; +use unicode_width::UnicodeWidthStr; + +pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; +const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; +const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; + +pub(crate) struct OutputLinesParams { + pub(crate) line_limit: usize, + pub(crate) only_err: bool, + pub(crate) include_angle_pipe: bool, + pub(crate) include_prefix: bool, +} + +pub(crate) fn new_active_exec_command( + call_id: String, + command: Vec, + parsed: Vec, + source: ExecCommandSource, + interaction_input: Option, + animations_enabled: bool, +) -> ExecCell { + ExecCell::new( + ExecCall { + call_id, + command, + parsed, + output: None, + source, + start_time: Some(Instant::now()), + duration: None, + interaction_input, + }, + animations_enabled, + ) +} + +fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { + let command_display = if let Some((_, script)) = extract_bash_command(command) { + script.to_string() + } else { + command.join(" ") + }; + match input { + Some(data) if !data.is_empty() => { + let preview = summarize_interaction_input(data); + format!("Interacted with `{command_display}`, sent `{preview}`") + } + _ => format!("Waited for `{command_display}`"), + } +} + +fn summarize_interaction_input(input: &str) -> String { + let single_line = input.replace('\n', "\\n"); + let sanitized = single_line.replace('`', "\\`"); + if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS { + return sanitized; + } + + let mut preview = String::new(); + for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) { + preview.push(ch); + } + preview.push_str("..."); + preview +} + +#[derive(Clone)] +pub(crate) struct OutputLines { + pub(crate) lines: Vec>, + pub(crate) omitted: Option, +} + +pub(crate) fn output_lines( + output: Option<&CommandOutput>, + params: OutputLinesParams, +) -> OutputLines { + let OutputLinesParams { + line_limit, + only_err, + include_angle_pipe, + include_prefix, + } = params; + let CommandOutput { + aggregated_output, .. + } = match output { + Some(output) if only_err && output.exit_code == 0 => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } + Some(output) => output, + None => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } + }; + + let src = aggregated_output; + let lines: Vec<&str> = src.lines().collect(); + let total = lines.len(); + let mut out: Vec> = Vec::new(); + + let head_end = total.min(line_limit); + for (i, raw) in lines[..head_end].iter().enumerate() { + let mut line = ansi_escape_line(raw); + let prefix = if !include_prefix { + "" + } else if i == 0 && include_angle_pipe { + " └ " + } else { + " " + }; + line.spans.insert(0, prefix.into()); + line.spans.iter_mut().for_each(|span| { + span.style = span.style.add_modifier(Modifier::DIM); + }); + out.push(line); + } + + let show_ellipsis = total > 2 * line_limit; + let omitted = if show_ellipsis { + Some(total - 2 * line_limit) + } else { + None + }; + if show_ellipsis { + let omitted = total - 2 * line_limit; + out.push(format!("… +{omitted} lines").into()); + } + + let tail_start = if show_ellipsis { + total - line_limit + } else { + head_end + }; + for raw in lines[tail_start..].iter() { + let mut line = ansi_escape_line(raw); + if include_prefix { + line.spans.insert(0, " ".into()); + } + line.spans.iter_mut().for_each(|span| { + span.style = span.style.add_modifier(Modifier::DIM); + }); + out.push(line); + } + + OutputLines { + lines: out, + omitted, + } +} + +pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { + if !animations_enabled { + return "•".dim(); + } + let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); + if supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false) + { + shimmer_spans("•")[0].clone() + } else { + let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); + if blink_on { "•".into() } else { "◦".dim() } + } +} + +impl HistoryCell for ExecCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.is_exploring_cell() { + self.exploring_display_lines(width) + } else { + self.command_display_lines(width) + } + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + self.transcript_lines(width).len() as u16 + } + + fn transcript_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = vec![]; + for (i, call) in self.iter_calls().enumerate() { + if i > 0 { + lines.push("".into()); + } + let script = strip_bash_lc_and_escape(&call.command); + let highlighted_script = highlight_bash_to_lines(&script); + let cmd_display = word_wrap_lines( + &highlighted_script, + RtOptions::new(width as usize) + .initial_indent("$ ".magenta().into()) + .subsequent_indent(" ".into()), + ); + lines.extend(cmd_display); + + if let Some(output) = call.output.as_ref() { + if !call.is_unified_exec_interaction() { + let wrap_width = width.max(1) as usize; + let wrap_opts = RtOptions::new(wrap_width); + for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { + let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + push_owned_lines(&wrapped, &mut lines); + } + } + let duration = call + .duration + .map(format_duration) + .unwrap_or_else(|| "unknown".to_string()); + let mut result: Line = if output.exit_code == 0 { + Line::from("✓".green().bold()) + } else { + Line::from(vec![ + "✗".red().bold(), + format!(" ({})", output.exit_code).into(), + ]) + }; + result.push_span(format!(" • {duration}").dim()); + lines.push(result); + } + } + lines + } +} + +impl ExecCell { + fn exploring_display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + out.push(Line::from(vec![ + if self.is_active() { + spinner(self.active_start_time(), self.animations_enabled()) + } else { + "•".dim() + }, + " ".into(), + if self.is_active() { + "Exploring".bold() + } else { + "Explored".bold() + }, + ])); + + let mut calls = self.calls.clone(); + let mut out_indented = Vec::new(); + while !calls.is_empty() { + let mut call = calls.remove(0); + if call + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) + { + while let Some(next) = calls.first() { + if next + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) + { + call.parsed.extend(next.parsed.clone()); + calls.remove(0); + } else { + break; + } + } + } + + let reads_only = call + .parsed + .iter() + .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })); + + let call_lines: Vec<(&str, Vec>)> = if reads_only { + let names = call + .parsed + .iter() + .map(|parsed| match parsed { + ParsedCommand::Read { name, .. } => name.clone(), + _ => unreachable!(), + }) + .unique(); + vec![( + "Read", + Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(), + )] + } else { + let mut lines = Vec::new(); + for parsed in &call.parsed { + match parsed { + ParsedCommand::Read { name, .. } => { + lines.push(("Read", vec![name.clone().into()])); + } + ParsedCommand::ListFiles { cmd, path } => { + lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()])); + } + ParsedCommand::Search { cmd, query, path } => { + let spans = match (query, path) { + (Some(q), Some(p)) => { + vec![q.clone().into(), " in ".dim(), p.clone().into()] + } + (Some(q), None) => vec![q.clone().into()], + _ => vec![cmd.clone().into()], + }; + lines.push(("Search", spans)); + } + ParsedCommand::Unknown { cmd } => { + lines.push(("Run", vec![cmd.clone().into()])); + } + } + } + lines + }; + + for (title, line) in call_lines { + let line = Line::from(line); + let initial_indent = Line::from(vec![title.cyan(), " ".into()]); + let subsequent_indent = " ".repeat(initial_indent.width()).into(); + let wrapped = word_wrap_line( + &line, + RtOptions::new(width as usize) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent), + ); + push_owned_lines(&wrapped, &mut out_indented); + } + } + + out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into())); + out + } + + fn command_display_lines(&self, width: u16) -> Vec> { + let [call] = &self.calls.as_slice() else { + panic!("Expected exactly one call in a command display cell"); + }; + let layout = EXEC_DISPLAY_LAYOUT; + let success = call.output.as_ref().map(|o| o.exit_code == 0); + let bullet = match success { + Some(true) => "•".green().bold(), + Some(false) => "•".red().bold(), + None => spinner(call.start_time, self.animations_enabled()), + }; + let is_interaction = call.is_unified_exec_interaction(); + let title = if is_interaction { + "" + } else if self.is_active() { + "Running" + } else if call.is_user_shell_command() { + "You ran" + } else { + "Ran" + }; + + let mut header_line = if is_interaction { + Line::from(vec![bullet.clone(), " ".into()]) + } else { + Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]) + }; + let header_prefix_width = header_line.width(); + + let cmd_display = if call.is_unified_exec_interaction() { + format_unified_exec_interaction(&call.command, call.interaction_input.as_deref()) + } else { + strip_bash_lc_and_escape(&call.command) + }; + let highlighted_lines = highlight_bash_to_lines(&cmd_display); + + let continuation_wrap_width = layout.command_continuation.wrap_width(width); + let continuation_opts = + RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation); + + let mut continuation_lines: Vec> = Vec::new(); + + if let Some((first, rest)) = highlighted_lines.split_first() { + let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); + let first_opts = + RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); + let mut first_wrapped: Vec> = Vec::new(); + push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); + let mut first_wrapped_iter = first_wrapped.into_iter(); + if let Some(first_segment) = first_wrapped_iter.next() { + header_line.extend(first_segment); + } + continuation_lines.extend(first_wrapped_iter); + + for line in rest { + push_owned_lines( + &word_wrap_line(line, continuation_opts.clone()), + &mut continuation_lines, + ); + } + } + + let mut lines: Vec> = vec![header_line]; + + let continuation_lines = Self::limit_lines_from_start( + &continuation_lines, + layout.command_continuation_max_lines, + ); + if !continuation_lines.is_empty() { + lines.extend(prefix_lines( + continuation_lines, + Span::from(layout.command_continuation.initial_prefix).dim(), + Span::from(layout.command_continuation.subsequent_prefix).dim(), + )); + } + + if let Some(output) = call.output.as_ref() { + let line_limit = if call.is_user_shell_command() { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + TOOL_CALL_MAX_LINES + }; + let raw_output = output_lines( + Some(output), + OutputLinesParams { + line_limit, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let display_limit = if call.is_user_shell_command() { + USER_SHELL_TOOL_CALL_MAX_LINES + } else { + layout.output_max_lines + }; + + if raw_output.lines.is_empty() { + if !call.is_unified_exec_interaction() { + lines.extend(prefix_lines( + vec![Line::from("(no output)".dim())], + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + )); + } + } else { + // Wrap first so that truncation is applied to on-screen lines + // rather than logical lines. This ensures that a small number + // of very long lines cannot flood the viewport. + let mut wrapped_output: Vec> = Vec::new(); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut wrapped_output, + ); + } + + let trimmed_output = + Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + + if !trimmed_output.is_empty() { + lines.extend(prefix_lines( + trimmed_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + )); + } + } + } + + lines + } + + fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { + if lines.len() <= keep { + return lines.to_vec(); + } + if keep == 0 { + return vec![Self::ellipsis_line(lines.len())]; + } + + let mut out: Vec> = lines[..keep].to_vec(); + out.push(Self::ellipsis_line(lines.len() - keep)); + out + } + + fn truncate_lines_middle( + lines: &[Line<'static>], + max: usize, + omitted_hint: Option, + ) -> Vec> { + if max == 0 { + return Vec::new(); + } + if lines.len() <= max { + return lines.to_vec(); + } + if max == 1 { + // Carry forward any previously omitted count and add any + // additionally hidden content lines from this truncation. + let base = omitted_hint.unwrap_or(0); + // When an existing ellipsis is present, `lines` already includes + // that single representation line; exclude it from the count of + // additionally omitted content lines. + let extra = lines + .len() + .saturating_sub(usize::from(omitted_hint.is_some())); + let omitted = base + extra; + return vec![Self::ellipsis_line(omitted)]; + } + + let head = (max - 1) / 2; + let tail = max - head - 1; + let mut out: Vec> = Vec::new(); + + if head > 0 { + out.extend(lines[..head].iter().cloned()); + } + + let base = omitted_hint.unwrap_or(0); + let additional = lines + .len() + .saturating_sub(head + tail) + .saturating_sub(usize::from(omitted_hint.is_some())); + out.push(Self::ellipsis_line(base + additional)); + + if tail > 0 { + out.extend(lines[lines.len() - tail..].iter().cloned()); + } + + out + } + + fn ellipsis_line(omitted: usize) -> Line<'static> { + Line::from(vec![format!("… +{omitted} lines").dim()]) + } +} + +#[derive(Clone, Copy)] +struct PrefixedBlock { + initial_prefix: &'static str, + subsequent_prefix: &'static str, +} + +impl PrefixedBlock { + const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self { + Self { + initial_prefix, + subsequent_prefix, + } + } + + fn wrap_width(self, total_width: u16) -> usize { + let prefix_width = UnicodeWidthStr::width(self.initial_prefix) + .max(UnicodeWidthStr::width(self.subsequent_prefix)); + usize::from(total_width).saturating_sub(prefix_width).max(1) + } +} + +#[derive(Clone, Copy)] +struct ExecDisplayLayout { + command_continuation: PrefixedBlock, + command_continuation_max_lines: usize, + output_block: PrefixedBlock, + output_max_lines: usize, +} + +impl ExecDisplayLayout { + const fn new( + command_continuation: PrefixedBlock, + command_continuation_max_lines: usize, + output_block: PrefixedBlock, + output_max_lines: usize, + ) -> Self { + Self { + command_continuation, + command_continuation_max_lines, + output_block, + output_max_lines, + } + } +} + +const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( + PrefixedBlock::new(" │ ", " │ "), + 2, + PrefixedBlock::new(" └ ", " "), + 5, +); + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::ExecCommandSource; + + #[test] + fn user_shell_output_is_limited_by_screen_lines() { + // Construct a user shell exec cell whose aggregated output consists of a + // small number of very long logical lines. These will wrap into many + // on-screen lines at narrow widths. + // + // Use a short marker so it survives wrapping intact inside each + // rendered screen line; the previous test used a marker longer than + // the wrap width, so it was split across lines and the assertion + // never actually saw it. + let marker = "Z"; + let long_chunk = marker.repeat(800); + let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + + // Baseline: how many screen lines would we get if we simply wrapped + // all logical lines without any truncation? + let output = CommandOutput { + exit_code: 0, + aggregated_output, + formatted_output: String::new(), + }; + let width = 20; + let layout = EXEC_DISPLAY_LAYOUT; + let raw_output = output_lines( + Some(&output), + OutputLinesParams { + // Large enough to include all logical lines without + // triggering the ellipsis in `output_lines`. + line_limit: 100, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + let mut full_wrapped_output: Vec> = Vec::new(); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut full_wrapped_output, + ); + } + let full_screen_lines = full_wrapped_output + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Sanity check: this scenario should produce more screen lines than + // the user shell per-call limit when no truncation is applied. If + // this ever fails, the test no longer exercises the regression. + assert!( + full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, + "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", + ); + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo long".into()], + parsed: Vec::new(), + output: Some(output), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + + // Use a narrow width so each logical line wraps into many on-screen lines. + let lines = cell.command_display_lines(width); + + // Count how many rendered lines contain our marker text. This approximates + // the number of visible output "screen lines" for this command. + let output_screen_lines = lines + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Regression guard: previously this scenario could render hundreds of + // wrapped lines because truncation happened before wrapping. Now the + // truncation is applied after wrapping, so the number of visible + // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + assert!( + output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + ); + } +} diff --git a/codex-rs/tui2/src/exec_command.rs b/codex-rs/tui2/src/exec_command.rs new file mode 100644 index 0000000000..8ce6c2632e --- /dev/null +++ b/codex-rs/tui2/src/exec_command.rs @@ -0,0 +1,70 @@ +use std::path::Path; +use std::path::PathBuf; + +use codex_core::parse_command::extract_shell_command; +use dirs::home_dir; +use shlex::try_join; + +pub(crate) fn escape_command(command: &[String]) -> String { + try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) +} + +pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { + if let Some((_, script)) = extract_shell_command(command) { + return script.to_string(); + } + escape_command(command) +} + +/// If `path` is absolute and inside $HOME, return the part *after* the home +/// directory; otherwise, return the path as-is. Note if `path` is the homedir, +/// this will return and empty path. +pub(crate) fn relativize_to_home

(path: P) -> Option +where + P: AsRef, +{ + let path = path.as_ref(); + if !path.is_absolute() { + // If the path is not absolute, we can’t do anything with it. + return None; + } + + let home_dir = home_dir()?; + let rel = path.strip_prefix(&home_dir).ok()?; + Some(rel.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_command() { + let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()]; + let cmdline = escape_command(&args); + assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'"); + } + + #[test] + fn test_strip_bash_lc_and_escape() { + // Test bash + let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test zsh + let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to zsh + let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to bash + let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + } +} diff --git a/codex-rs/tui2/src/file_search.rs b/codex-rs/tui2/src/file_search.rs new file mode 100644 index 0000000000..af46512640 --- /dev/null +++ b/codex-rs/tui2/src/file_search.rs @@ -0,0 +1,199 @@ +//! Helper that owns the debounce/cancellation logic for `@` file searches. +//! +//! `ChatComposer` publishes *every* change of the `@token` as +//! `AppEvent::StartFileSearch(query)`. +//! This struct receives those events and decides when to actually spawn the +//! expensive search (handled in the main `App` thread). It tries to ensure: +//! +//! - Even when the user types long text quickly, they will start seeing results +//! after a short delay using an early version of what they typed. +//! - At most one search is in-flight at any time. +//! +//! It works as follows: +//! +//! 1. First query starts a debounce timer. +//! 2. While the timer is pending, the latest query from the user is stored. +//! 3. When the timer fires, it is cleared, and a search is done for the most +//! recent query. +//! 4. If there is a in-flight search that is not a prefix of the latest thing +//! the user typed, it is cancelled. + +use codex_file_search as file_search; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap(); +const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); + +/// How long to wait after a keystroke before firing the first search when none +/// is currently running. Keeps early queries more meaningful. +const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); + +const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// State machine for file-search orchestration. +pub(crate) struct FileSearchManager { + /// Unified state guarded by one mutex. + state: Arc>, + + search_dir: PathBuf, + app_tx: AppEventSender, +} + +struct SearchState { + /// Latest query typed by user (updated every keystroke). + latest_query: String, + + /// true if a search is currently scheduled. + is_search_scheduled: bool, + + /// If there is an active search, this will be the query being searched. + active_search: Option, +} + +struct ActiveSearch { + query: String, + cancellation_token: Arc, +} + +impl FileSearchManager { + pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { + Self { + state: Arc::new(Mutex::new(SearchState { + latest_query: String::new(), + is_search_scheduled: false, + active_search: None, + })), + search_dir, + app_tx: tx, + } + } + + /// Call whenever the user edits the `@` token. + pub fn on_user_query(&self, query: String) { + { + #[expect(clippy::unwrap_used)] + let mut st = self.state.lock().unwrap(); + if query == st.latest_query { + // No change, nothing to do. + return; + } + + // Update latest query. + st.latest_query.clear(); + st.latest_query.push_str(&query); + + // If there is an in-flight search that is definitely obsolete, + // cancel it now. + if let Some(active_search) = &st.active_search + && !query.starts_with(&active_search.query) + { + active_search + .cancellation_token + .store(true, Ordering::Relaxed); + st.active_search = None; + } + + // Schedule a search to run after debounce. + if !st.is_search_scheduled { + st.is_search_scheduled = true; + } else { + return; + } + } + + // If we are here, we set `st.is_search_scheduled = true` before + // dropping the lock. This means we are the only thread that can spawn a + // debounce timer. + let state = self.state.clone(); + let search_dir = self.search_dir.clone(); + let tx_clone = self.app_tx.clone(); + thread::spawn(move || { + // Always do a minimum debounce, but then poll until the + // `active_search` is cleared. + thread::sleep(FILE_SEARCH_DEBOUNCE); + loop { + #[expect(clippy::unwrap_used)] + if state.lock().unwrap().active_search.is_none() { + break; + } + thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); + } + + // The debounce timer has expired, so start a search using the + // latest query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + let query = { + #[expect(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + let query = st.latest_query.clone(); + st.is_search_scheduled = false; + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + query + }; + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + }); + } + + fn spawn_file_search( + query: String, + search_dir: PathBuf, + tx: AppEventSender, + cancellation_token: Arc, + search_state: Arc>, + ) { + let compute_indices = true; + std::thread::spawn(move || { + let matches = file_search::run( + &query, + MAX_FILE_SEARCH_RESULTS, + &search_dir, + Vec::new(), + NUM_FILE_SEARCH_THREADS, + cancellation_token.clone(), + compute_indices, + true, + ) + .map(|res| res.matches) + .unwrap_or_default(); + + let is_cancelled = cancellation_token.load(Ordering::Relaxed); + if !is_cancelled { + tx.send(AppEvent::FileSearchResult { query, matches }); + } + + // Reset the active search state. Do a pointer comparison to verify + // that we are clearing the ActiveSearch that corresponds to the + // cancellation token we were given. + { + #[expect(clippy::unwrap_used)] + let mut st = search_state.lock().unwrap(); + if let Some(active_search) = &st.active_search + && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) + { + st.active_search = None; + } + } + }); + } +} diff --git a/codex-rs/tui2/src/frames.rs b/codex-rs/tui2/src/frames.rs new file mode 100644 index 0000000000..19a70578d4 --- /dev/null +++ b/codex-rs/tui2/src/frames.rs @@ -0,0 +1,71 @@ +use std::time::Duration; + +// Embed animation frames for each variant at compile time. +macro_rules! frames_for { + ($dir:literal) => { + [ + include_str!(concat!("../frames/", $dir, "/frame_1.txt")), + include_str!(concat!("../frames/", $dir, "/frame_2.txt")), + include_str!(concat!("../frames/", $dir, "/frame_3.txt")), + include_str!(concat!("../frames/", $dir, "/frame_4.txt")), + include_str!(concat!("../frames/", $dir, "/frame_5.txt")), + include_str!(concat!("../frames/", $dir, "/frame_6.txt")), + include_str!(concat!("../frames/", $dir, "/frame_7.txt")), + include_str!(concat!("../frames/", $dir, "/frame_8.txt")), + include_str!(concat!("../frames/", $dir, "/frame_9.txt")), + include_str!(concat!("../frames/", $dir, "/frame_10.txt")), + include_str!(concat!("../frames/", $dir, "/frame_11.txt")), + include_str!(concat!("../frames/", $dir, "/frame_12.txt")), + include_str!(concat!("../frames/", $dir, "/frame_13.txt")), + include_str!(concat!("../frames/", $dir, "/frame_14.txt")), + include_str!(concat!("../frames/", $dir, "/frame_15.txt")), + include_str!(concat!("../frames/", $dir, "/frame_16.txt")), + include_str!(concat!("../frames/", $dir, "/frame_17.txt")), + include_str!(concat!("../frames/", $dir, "/frame_18.txt")), + include_str!(concat!("../frames/", $dir, "/frame_19.txt")), + include_str!(concat!("../frames/", $dir, "/frame_20.txt")), + include_str!(concat!("../frames/", $dir, "/frame_21.txt")), + include_str!(concat!("../frames/", $dir, "/frame_22.txt")), + include_str!(concat!("../frames/", $dir, "/frame_23.txt")), + include_str!(concat!("../frames/", $dir, "/frame_24.txt")), + include_str!(concat!("../frames/", $dir, "/frame_25.txt")), + include_str!(concat!("../frames/", $dir, "/frame_26.txt")), + include_str!(concat!("../frames/", $dir, "/frame_27.txt")), + include_str!(concat!("../frames/", $dir, "/frame_28.txt")), + include_str!(concat!("../frames/", $dir, "/frame_29.txt")), + include_str!(concat!("../frames/", $dir, "/frame_30.txt")), + include_str!(concat!("../frames/", $dir, "/frame_31.txt")), + include_str!(concat!("../frames/", $dir, "/frame_32.txt")), + include_str!(concat!("../frames/", $dir, "/frame_33.txt")), + include_str!(concat!("../frames/", $dir, "/frame_34.txt")), + include_str!(concat!("../frames/", $dir, "/frame_35.txt")), + include_str!(concat!("../frames/", $dir, "/frame_36.txt")), + ] + }; +} + +pub(crate) const FRAMES_DEFAULT: [&str; 36] = frames_for!("default"); +pub(crate) const FRAMES_CODEX: [&str; 36] = frames_for!("codex"); +pub(crate) const FRAMES_OPENAI: [&str; 36] = frames_for!("openai"); +pub(crate) const FRAMES_BLOCKS: [&str; 36] = frames_for!("blocks"); +pub(crate) const FRAMES_DOTS: [&str; 36] = frames_for!("dots"); +pub(crate) const FRAMES_HASH: [&str; 36] = frames_for!("hash"); +pub(crate) const FRAMES_HBARS: [&str; 36] = frames_for!("hbars"); +pub(crate) const FRAMES_VBARS: [&str; 36] = frames_for!("vbars"); +pub(crate) const FRAMES_SHAPES: [&str; 36] = frames_for!("shapes"); +pub(crate) const FRAMES_SLUG: [&str; 36] = frames_for!("slug"); + +pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ + &FRAMES_DEFAULT, + &FRAMES_CODEX, + &FRAMES_OPENAI, + &FRAMES_BLOCKS, + &FRAMES_DOTS, + &FRAMES_HASH, + &FRAMES_HBARS, + &FRAMES_VBARS, + &FRAMES_SHAPES, + &FRAMES_SLUG, +]; + +pub(crate) const FRAME_TICK_DEFAULT: Duration = Duration::from_millis(80); diff --git a/codex-rs/tui2/src/get_git_diff.rs b/codex-rs/tui2/src/get_git_diff.rs new file mode 100644 index 0000000000..78ab53d92f --- /dev/null +++ b/codex-rs/tui2/src/get_git_diff.rs @@ -0,0 +1,119 @@ +//! Utility to compute the current Git diff for the working directory. +//! +//! The implementation mirrors the behaviour of the TypeScript version in +//! `codex-cli`: it returns the diff for tracked changes as well as any +//! untracked files. When the current directory is not inside a Git +//! repository, the function returns `Ok((false, String::new()))`. + +use std::io; +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +/// Return value of [`get_git_diff`]. +/// +/// * `bool` – Whether the current working directory is inside a Git repo. +/// * `String` – The concatenated diff (may be empty). +pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { + // First check if we are inside a Git repository. + if !inside_git_repo().await? { + return Ok((false, String::new())); + } + + // Run tracked diff and untracked file listing in parallel. + let (tracked_diff_res, untracked_output_res) = tokio::join!( + run_git_capture_diff(&["diff", "--color"]), + run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]), + ); + let tracked_diff = tracked_diff_res?; + let untracked_output = untracked_output_res?; + + let mut untracked_diff = String::new(); + let null_device: &Path = if cfg!(windows) { + Path::new("NUL") + } else { + Path::new("/dev/null") + }; + + let null_path = null_device.to_str().unwrap_or("/dev/null").to_string(); + let mut join_set: tokio::task::JoinSet> = tokio::task::JoinSet::new(); + for file in untracked_output + .split('\n') + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let null_path = null_path.clone(); + let file = file.to_string(); + join_set.spawn(async move { + let args = ["diff", "--color", "--no-index", "--", &null_path, &file]; + run_git_capture_diff(&args).await + }); + } + while let Some(res) = join_set.join_next().await { + match res { + Ok(Ok(diff)) => untracked_diff.push_str(&diff), + Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {} + Ok(Err(err)) => return Err(err), + Err(_) => {} + } + } + + Ok((true, format!("{tracked_diff}{untracked_diff}"))) +} + +/// Helper that executes `git` with the given `args` and returns `stdout` as a +/// UTF-8 string. Any non-zero exit status is considered an *error*. +async fn run_git_capture_stdout(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and +/// returns stdout. Git returns 1 for diffs when differences are present. +async fn run_git_capture_diff(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + + if output.status.success() || output.status.code() == Some(1) { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Determine if the current directory is inside a Git repository. +async fn inside_git_repo() -> io::Result { + let status = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + match status { + Ok(s) if s.success() => Ok(true), + Ok(_) => Ok(false), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed + Err(e) => Err(e), + } +} diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs new file mode 100644 index 0000000000..4147067366 --- /dev/null +++ b/codex-rs/tui2/src/history_cell.rs @@ -0,0 +1,2435 @@ +use crate::diff_render::create_diff_summary; +use crate::diff_render::display_path_for; +use crate::exec_cell::CommandOutput; +use crate::exec_cell::OutputLinesParams; +use crate::exec_cell::TOOL_CALL_MAX_LINES; +use crate::exec_cell::output_lines; +use crate::exec_cell::spinner; +use crate::exec_command::relativize_to_home; +use crate::exec_command::strip_bash_lc_and_escape; +use crate::markdown::append_markdown; +use crate::render::line_utils::line_to_static; +use crate::render::line_utils::prefix_lines; +use crate::render::line_utils::push_owned_lines; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; +use crate::text_formatting::format_and_truncate_tool_result; +use crate::text_formatting::truncate_text; +use crate::tooltips; +use crate::ui_consts::LIVE_PREFIX_COLS; +use crate::update_action::UpdateAction; +use crate::version::CODEX_CLI_VERSION; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines; +use base64::Engine; +use codex_common::format_env_display::format_env_display; +use codex_core::config::Config; +use codex_core::config::types::McpServerTransportConfig; +use codex_core::config::types::ReasoningSummaryFormat; +use codex_core::protocol::FileChange; +use codex_core::protocol::McpAuthStatus; +use codex_core::protocol::McpInvocation; +use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::plan_tool::PlanItemArg; +use codex_protocol::plan_tool::StepStatus; +use codex_protocol::plan_tool::UpdatePlanArgs; +use image::DynamicImage; +use image::ImageReader; +use mcp_types::EmbeddedResourceResource; +use mcp_types::Resource; +use mcp_types::ResourceLink; +use mcp_types::ResourceTemplate; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::any::Any; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; +use tracing::error; +use unicode_width::UnicodeWidthStr; + +/// Represents an event to display in the conversation history. Returns its +/// `Vec>` representation to make it easier to display in a +/// scrollable list. +pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { + fn display_lines(&self, width: u16) -> Vec>; + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.display_lines(width))) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + let lines = self.transcript_lines(width); + // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. + if let [line] = &lines[..] + && line + .spans + .iter() + .all(|s| s.content.chars().all(char::is_whitespace)) + { + return 1; + } + + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } + + fn is_stream_continuation(&self) -> bool { + false + } +} + +impl Renderable for Box { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.display_lines(area.width); + let y = if area.height == 0 { + 0 + } else { + let overflow = lines.len().saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Paragraph::new(Text::from(lines)) + .scroll((y, 0)) + .render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + HistoryCell::desired_height(self.as_ref(), width) + } +} + +impl dyn HistoryCell { + pub(crate) fn as_any(&self) -> &dyn Any { + self + } + + pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +#[derive(Debug)] +pub(crate) struct UserHistoryCell { + pub message: String, +} + +impl HistoryCell for UserHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + + let wrap_width = width + .saturating_sub( + LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ + ) + .max(1); + + let style = user_message_style(); + + let wrapped = word_wrap_lines( + self.message.lines().map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ); + + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); + lines.push(Line::from("").style(style)); + lines + } +} + +#[derive(Debug)] +pub(crate) struct ReasoningSummaryCell { + _header: String, + content: String, + transcript_only: bool, +} + +impl ReasoningSummaryCell { + pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { + Self { + _header: header, + content, + transcript_only, + } + } + + fn lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + append_markdown( + &self.content, + Some((width as usize).saturating_sub(2)), + &mut lines, + ); + let summary_style = Style::default().dim().italic(); + let summary_lines = lines + .into_iter() + .map(|mut line| { + line.spans = line + .spans + .into_iter() + .map(|span| span.patch_style(summary_style)) + .collect(); + line + }) + .collect::>(); + + word_wrap_lines( + &summary_lines, + RtOptions::new(width as usize) + .initial_indent("• ".dim().into()) + .subsequent_indent(" ".into()), + ) + } +} + +impl HistoryCell for ReasoningSummaryCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.transcript_only { + Vec::new() + } else { + self.lines(width) + } + } + + fn desired_height(&self, width: u16) -> u16 { + if self.transcript_only { + 0 + } else { + self.lines(width).len() as u16 + } + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +#[derive(Debug)] +pub(crate) struct AgentMessageCell { + lines: Vec>, + is_first_line: bool, +} + +impl AgentMessageCell { + pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines, + is_first_line, + } + } +} + +impl HistoryCell for AgentMessageCell { + fn display_lines(&self, width: u16) -> Vec> { + word_wrap_lines( + &self.lines, + RtOptions::new(width as usize) + .initial_indent(if self.is_first_line { + "• ".dim().into() + } else { + " ".into() + }) + .subsequent_indent(" ".into()), + ) + } + + fn is_stream_continuation(&self) -> bool { + !self.is_first_line + } +} + +#[derive(Debug)] +pub(crate) struct PlainHistoryCell { + lines: Vec>, +} + +impl PlainHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for PlainHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +#[derive(Debug)] +pub(crate) struct UpdateAvailableHistoryCell { + latest_version: String, + update_action: Option, +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +impl UpdateAvailableHistoryCell { + pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + Self { + latest_version, + update_action, + } + } +} + +impl HistoryCell for UpdateAvailableHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + use ratatui_macros::line; + use ratatui_macros::text; + let update_instruction = if let Some(update_action) = self.update_action { + line!["Run ", update_action.command_str().cyan(), " to update."] + } else { + line![ + "See ", + "https://github.com/openai/codex".cyan().underlined(), + " for installation options." + ] + }; + + let content = text![ + line![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ", + format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + ], + update_instruction, + "", + "See full release notes:", + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ]; + + let inner_width = content + .width() + .min(usize::from(width.saturating_sub(4))) + .max(1); + with_border_with_inner_width(content.lines, inner_width) + } +} + +#[derive(Debug)] +pub(crate) struct PrefixedWrappedHistoryCell { + text: Text<'static>, + initial_prefix: Line<'static>, + subsequent_prefix: Line<'static>, +} + +impl PrefixedWrappedHistoryCell { + pub(crate) fn new( + text: impl Into>, + initial_prefix: impl Into>, + subsequent_prefix: impl Into>, + ) -> Self { + Self { + text: text.into(), + initial_prefix: initial_prefix.into(), + subsequent_prefix: subsequent_prefix.into(), + } + } +} + +impl HistoryCell for PrefixedWrappedHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + let opts = RtOptions::new(width.max(1) as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()); + let wrapped = word_wrap_lines(&self.text, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +fn truncate_exec_snippet(full_cmd: &str) -> String { + let mut snippet = match full_cmd.split_once('\n') { + Some((first, _)) => format!("{first} ..."), + None => full_cmd.to_string(), + }; + snippet = truncate_text(&snippet, 80); + snippet +} + +fn exec_snippet(command: &[String]) -> String { + let full_cmd = strip_bash_lc_and_escape(command); + truncate_exec_snippet(&full_cmd) +} + +pub fn new_approval_decision_cell( + command: Vec, + decision: codex_core::protocol::ReviewDecision, +) -> Box { + use codex_core::protocol::ReviewDecision::*; + + let (symbol, summary): (Span<'static>, Vec>) = match decision { + Approved => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " this time".bold(), + ], + ) + } + ApprovedExecpolicyAmendment { .. } => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and applied the execpolicy amendment".bold(), + ], + ) + } + ApprovedForSession => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " every time this session".bold(), + ], + ) + } + Denied => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "did not approve".bold(), + " codex to run ".into(), + snippet, + ], + ) + } + Abort => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "canceled".bold(), + " the request to run ".into(), + snippet, + ], + ) + } + }; + + Box::new(PrefixedWrappedHistoryCell::new( + Line::from(summary), + symbol, + " ", + )) +} + +/// Cyan history cell line showing the current review status. +pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![Line::from(message.cyan())], + } +} + +#[derive(Debug)] +pub(crate) struct PatchHistoryCell { + changes: HashMap, + cwd: PathBuf, +} + +impl HistoryCell for PatchHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + create_diff_summary(&self.changes, &self.cwd, width as usize) + } +} + +#[derive(Debug)] +struct CompletedMcpToolCallWithImageOutput { + _image: DynamicImage, +} +impl HistoryCell for CompletedMcpToolCallWithImageOutput { + fn display_lines(&self, _width: u16) -> Vec> { + vec!["tool result (image output)".into()] + } +} + +pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value + +pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { + if width < 4 { + return None; + } + let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); + Some(inner_width) +} + +/// Render `lines` inside a border sized to the widest span in the content. +pub(crate) fn with_border(lines: Vec>) -> Vec> { + with_border_internal(lines, None) +} + +/// Render `lines` inside a border whose inner width is at least `inner_width`. +/// +/// This is useful when callers have already clamped their content to a +/// specific width and want the border math centralized here instead of +/// duplicating padding logic in the TUI widgets themselves. +pub(crate) fn with_border_with_inner_width( + lines: Vec>, + inner_width: usize, +) -> Vec> { + with_border_internal(lines, Some(inner_width)) +} + +fn with_border_internal( + lines: Vec>, + forced_inner_width: Option, +) -> Vec> { + let max_line_width = lines + .iter() + .map(|line| { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum::() + }) + .max() + .unwrap_or(0); + let content_width = forced_inner_width + .unwrap_or(max_line_width) + .max(max_line_width); + + let mut out = Vec::with_capacity(lines.len() + 2); + let border_inner_width = content_width + 2; + out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); + + for line in lines.into_iter() { + let used_width: usize = line + .iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum(); + let span_count = line.spans.len(); + let mut spans: Vec> = Vec::with_capacity(span_count + 4); + spans.push(Span::from("│ ").dim()); + spans.extend(line.into_iter()); + if used_width < content_width { + spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); + } + spans.push(Span::from(" │").dim()); + out.push(Line::from(spans)); + } + + out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); + + out +} + +/// Return the emoji followed by a hair space (U+200A). +/// Using only the hair space avoids excessive padding after the emoji while +/// still providing a small visual gap across terminals. +pub(crate) fn padded_emoji(emoji: &str) -> String { + format!("{emoji}\u{200A}") +} + +#[derive(Debug)] +struct TooltipHistoryCell { + tip: &'static str, +} + +impl TooltipHistoryCell { + fn new(tip: &'static str) -> Self { + Self { tip } + } +} + +impl HistoryCell for TooltipHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let wrap_width = usize::from(width.max(1)) + .saturating_sub(indent_width) + .max(1); + let mut lines: Vec> = Vec::new(); + append_markdown( + &format!("**Tip:** {}", self.tip), + Some(wrap_width), + &mut lines, + ); + + prefix_lines(lines, indent.into(), indent.into()) + } +} + +#[derive(Debug)] +pub struct SessionInfoCell(CompositeHistoryCell); + +impl HistoryCell for SessionInfoCell { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn desired_height(&self, width: u16) -> u16 { + self.0.desired_height(width) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.0.transcript_lines(width) + } +} + +pub(crate) fn new_session_info( + config: &Config, + requested_model: &str, + event: SessionConfiguredEvent, + is_first_event: bool, +) -> SessionInfoCell { + let SessionConfiguredEvent { + model, + reasoning_effort, + .. + } = event; + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( + model.clone(), + reasoning_effort, + config.cwd.clone(), + CODEX_CLI_VERSION, + ); + let mut parts: Vec> = vec![Box::new(header)]; + + if is_first_event { + // Help lines below the header (new copy and list) + let help_lines: Vec> = vec![ + " To get started, describe a task or try one of these commands:" + .dim() + .into(), + Line::from(""), + Line::from(vec![ + " ".into(), + "/init".into(), + " - create an AGENTS.md file with instructions for Codex".dim(), + ]), + Line::from(vec![ + " ".into(), + "/status".into(), + " - show current session configuration".dim(), + ]), + Line::from(vec![ + " ".into(), + "/approvals".into(), + " - choose what Codex can do without approval".dim(), + ]), + Line::from(vec![ + " ".into(), + "/model".into(), + " - choose what model and reasoning effort to use".dim(), + ]), + Line::from(vec![ + " ".into(), + "/review".into(), + " - review any changes and find issues".dim(), + ]), + ]; + + parts.push(Box::new(PlainHistoryCell { lines: help_lines })); + } else { + if config.show_tooltips + && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) + { + parts.push(Box::new(tooltips)); + } + if requested_model != model { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {requested_model}").into(), + format!("used: {model}").into(), + ]; + parts.push(Box::new(PlainHistoryCell { lines })); + } + } + + SessionInfoCell(CompositeHistoryCell { parts }) +} + +pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { + UserHistoryCell { message } +} + +#[derive(Debug)] +struct SessionHeaderHistoryCell { + version: &'static str, + model: String, + reasoning_effort: Option, + directory: PathBuf, +} + +impl SessionHeaderHistoryCell { + fn new( + model: String, + reasoning_effort: Option, + directory: PathBuf, + version: &'static str, + ) -> Self { + Self { + version, + model, + reasoning_effort, + directory, + } + } + + fn format_directory(&self, max_width: Option) -> String { + Self::format_directory_inner(&self.directory, max_width) + } + + fn format_directory_inner(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(formatted.as_str()) > max_width { + return crate::text_formatting::center_truncate_path(&formatted, max_width); + } + } + + formatted + } + + fn reasoning_label(&self) -> Option<&'static str> { + self.reasoning_effort.map(|effort| match effort { + ReasoningEffortConfig::Minimal => "minimal", + ReasoningEffortConfig::Low => "low", + ReasoningEffortConfig::Medium => "medium", + ReasoningEffortConfig::High => "high", + ReasoningEffortConfig::XHigh => "xhigh", + ReasoningEffortConfig::None => "none", + }) + } +} + +impl HistoryCell for SessionHeaderHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { + return Vec::new(); + }; + + let make_row = |spans: Vec>| Line::from(spans); + + // Title line rendered inside the box: ">_ OpenAI Codex (vX)" + let title_spans: Vec> = vec![ + Span::from(">_ ").dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{})", self.version)).dim(), + ]; + + const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; + const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const DIR_LABEL: &str = "directory:"; + let label_width = DIR_LABEL.len(); + let model_label = format!( + "{model_label:> = vec![ + Span::from(format!("{model_label} ")).dim(), + Span::from(self.model.clone()), + ]; + if let Some(reasoning) = reasoning_label { + model_spans.push(Span::from(" ")); + model_spans.push(Span::from(reasoning)); + } + model_spans.push(" ".dim()); + model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); + model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let dir_label = format!("{DIR_LABEL:>, +} + +impl CompositeHistoryCell { + pub(crate) fn new(parts: Vec>) -> Self { + Self { parts } + } +} + +impl HistoryCell for CompositeHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_lines(width); + if !lines.is_empty() { + if !first { + out.push(Line::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } +} + +#[derive(Debug)] +pub(crate) struct McpToolCallCell { + call_id: String, + invocation: McpInvocation, + start_time: Instant, + duration: Option, + result: Option>, + animations_enabled: bool, +} + +impl McpToolCallCell { + pub(crate) fn new( + call_id: String, + invocation: McpInvocation, + animations_enabled: bool, + ) -> Self { + Self { + call_id, + invocation, + start_time: Instant::now(), + duration: None, + result: None, + animations_enabled, + } + } + + pub(crate) fn call_id(&self) -> &str { + &self.call_id + } + + pub(crate) fn complete( + &mut self, + duration: Duration, + result: Result, + ) -> Option> { + let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) + .map(|cell| Box::new(cell) as Box); + self.duration = Some(duration); + self.result = Some(result); + image_cell + } + + fn success(&self) -> Option { + match self.result.as_ref() { + Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), + Some(Err(_)) => Some(false), + None => None, + } + } + + pub(crate) fn mark_failed(&mut self) { + let elapsed = self.start_time.elapsed(); + self.duration = Some(elapsed); + self.result = Some(Err("interrupted".to_string())); + } + + fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { + match block { + mcp_types::ContentBlock::TextContent(text) => { + format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) + } + mcp_types::ContentBlock::ImageContent(_) => "".to_string(), + mcp_types::ContentBlock::AudioContent(_) => "