diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 805be85af88a..138796e5d66b 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; } diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9fcc969b388..4429858c912b 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", ] @@ -1625,6 +1623,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.1", "url", + "uuid", "vt100", ] diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 51d1bd2361f1..28583667393e 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, }, + McpServersList => "mcpServers/list" { + params: v2::ListMcpServersParams, + response: v2::ListMcpServersResponse, + }, + LoginAccount => "account/login/start" { params: v2::LoginAccountParams, response: v2::LoginAccountResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 54f80c9fd48a..1576eb0d931f 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 0e2be70c932e..f1d8392135bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2,14 +2,13 @@ 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; 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; @@ -22,6 +21,9 @@ use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; +use mcp_types::Tool as McpTool; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -138,6 +140,15 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -615,13 +626,44 @@ pub struct ModelListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServer { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FeedbackUploadParams { pub classification: String, pub reason: Option, - pub conversation_id: Option, + pub thread_id: Option, pub include_logs: bool, } @@ -1101,9 +1143,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")] @@ -1206,15 +1245,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)] @@ -1264,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/Cargo.toml b/codex-rs/app-server/Cargo.toml index 4ffe2d8913e5..99d5a7a14109 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -31,6 +31,7 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e94a2c1331e..ee28637b2a0f 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. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b4dd16b9a618..df4cdb8980de 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); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cd8aa9b7042d..65721a698ef2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -45,6 +45,8 @@ use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::ListMcpServersParams; +use codex_app_server_protocol::ListMcpServersResponse; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; @@ -52,6 +54,7 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification; 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::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -119,6 +122,8 @@ use codex_core::exec_env::create_env; use codex_core::features::Feature; use codex_core::find_conversation_path_by_id_str; use codex_core::git_info::git_diff_to_remote; +use codex_core::mcp::collect_mcp_snapshot; +use codex_core::mcp::group_tools_by_server; use codex_core::parse_cursor; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -136,6 +141,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -363,6 +369,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServersList { request_id, params } => { + self.list_mcp_servers(request_id, params).await; + } ClientRequest::LoginAccount { request_id, params } => { self.login_v2(request_id, params).await; } @@ -1853,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 { @@ -1908,6 +1916,85 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { + let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; + + let tools_by_server = group_tools_by_server(&snapshot.tools); + + let mut server_names: Vec = self + .config + .mcp_servers + .keys() + .cloned() + .chain(snapshot.auth_statuses.keys().cloned()) + .chain(snapshot.resources.keys().cloned()) + .chain(snapshot.resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); + + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => 0, + }; + + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total MCP servers {total}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServer { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: snapshot.resources.get(name).cloned().unwrap_or_default(), + resource_templates: snapshot + .resource_templates + .get(name) + .cloned() + .unwrap_or_default(), + auth_status: snapshot + .auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); + + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + let response = ListMcpServersResponse { data, next_cursor }; + + self.outgoing.send_response(request_id, response).await; + } + async fn handle_resume_conversation( &self, request_id: RequestId, @@ -2933,10 +3020,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(); diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index d03795c2d41b..3ac71e85b904 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_common::model_presets::ModelPreset; -use codex_common::model_presets::ReasoningEffortPreset; -use codex_common::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() @@ -27,7 +30,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 a64aca805113..4b206436c860 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 75dba5722930..88e74a6fb4c3 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 3c4844fed97f..8ca85c9c3b9b 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 03ee279e5196..e4cd7229474e 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 addab02dc7cd..19e82de33215 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 377d0544830d..25264eff09fb 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 5092b3be2477..d5513b8325be 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/auth.rs b/codex-rs/core/src/auth.rs index d874435e8e63..a5c9add53f5c 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/client.rs b/codex-rs/core/src/client.rs index 82839522c9d3..f4248f30abf0 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 f76cd7de7446..885a4cdf74cb 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; @@ -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; @@ -189,7 +190,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, }; @@ -263,6 +263,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, @@ -343,8 +346,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, @@ -400,6 +401,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(); @@ -407,6 +409,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); } @@ -429,7 +432,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features: &config.features, + features, }); TurnContext { @@ -515,7 +518,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 { @@ -574,6 +577,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), @@ -1037,7 +1041,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; } @@ -1066,13 +1070,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]) { @@ -1255,7 +1254,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 { @@ -1493,6 +1492,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; + use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -1689,30 +1689,18 @@ mod handlers { pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let (tools, auth_status_entries, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), + let snapshot = collect_mcp_snapshot_from_manager( + &mcp_connection_manager, compute_auth_statuses( config.mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, - ), - mcp_connection_manager.list_all_resources(), - mcp_connection_manager.list_all_resource_templates(), - ); - let auth_statuses = auth_status_entries - .iter() - .map(|(name, entry)| (name.clone(), entry.auth_status)) - .collect(); + ) + .await, + ) + .await; let event = Event { id: sub_id, - msg: EventMsg::McpListToolsResponse(crate::protocol::McpListToolsResponseEvent { - tools: tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - resources, - resource_templates, - auth_statuses, - }), + msg: EventMsg::McpListToolsResponse(snapshot), }; sess.send_event_raw(event).await; } @@ -1839,7 +1827,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); @@ -1860,6 +1848,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); } @@ -2007,7 +1996,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 { @@ -2090,14 +2079,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"); @@ -2473,7 +2455,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; @@ -2586,7 +2567,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, }; @@ -2785,7 +2765,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, }; @@ -2818,6 +2797,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), @@ -2863,7 +2843,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, }; @@ -2896,6 +2875,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), @@ -2906,15 +2886,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 fb5c187b7f1c..7ce325a75a10 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/config/edit.rs b/codex-rs/core/src/config/edit.rs index b8862fa5c5ad..68e2d206f0dc 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 5e768ba9d446..dccf0556f16e 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; @@ -161,6 +161,9 @@ pub struct Config { /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, + /// Show startup tooltips in the TUI welcome screen. + pub show_tooltips: bool, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1252,6 +1255,7 @@ impl Config { .map(|t| t.notifications.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), + show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -1426,6 +1430,7 @@ persistence = "none" let tui = parsed.tui.expect("config should include tui section"); assert_eq!(tui.notifications, Notifications::Enabled(true)); + assert!(tui.show_tooltips); } #[test] @@ -2999,6 +3004,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }, o3_profile_config @@ -3072,6 +3078,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3160,6 +3167,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3234,6 +3242,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 3d9e60b8e5f6..5629465c404a 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/config/types.rs b/codex-rs/core/src/config/types.rs index c47a5709692d..5e1b78aa7be2 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -368,6 +368,11 @@ pub struct Tui { /// Defaults to `true`. #[serde(default = "default_true")] pub animations: bool, + + /// Show startup tooltips in the TUI welcome screen. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tooltips: bool, } const fn default_true() -> bool { diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 0f4577bf1e84..f41e5b597f21 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,14 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } + + 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/exec.rs b/codex-rs/core/src/exec.rs index f46444675fc8..ba1ac430040c 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/lib.rs b/codex-rs/core/src/lib.rs index d9ab6ee51fda..d32366476a72 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/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 0e4a05d5978f..ed5f2ea69f60 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -1 +1,168 @@ pub mod auth; +use std::collections::HashMap; + +use async_channel::unbounded; +use codex_protocol::protocol::McpListToolsResponseEvent; +use mcp_types::Tool as McpTool; +use tokio_util::sync::CancellationToken; + +use crate::config::Config; +use crate::mcp::auth::compute_auth_statuses; +use crate::mcp_connection_manager::McpConnectionManager; + +const MCP_TOOL_NAME_PREFIX: &str = "mcp"; +const MCP_TOOL_NAME_DELIMITER: &str = "__"; + +pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { + if config.mcp_servers.is_empty() { + return McpListToolsResponseEvent { + tools: HashMap::new(), + resources: HashMap::new(), + resource_templates: HashMap::new(), + auth_statuses: HashMap::new(), + }; + } + + let auth_status_entries = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; + + let mut mcp_connection_manager = McpConnectionManager::default(); + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + let cancel_token = CancellationToken::new(); + + mcp_connection_manager + .initialize( + config.mcp_servers.clone(), + config.mcp_oauth_credentials_store_mode, + auth_status_entries.clone(), + tx_event, + cancel_token.clone(), + ) + .await; + + let snapshot = + collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; + + cancel_token.cancel(); + + snapshot +} + +pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> { + let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER); + let prefix = parts.next()?; + if prefix != MCP_TOOL_NAME_PREFIX { + return None; + } + let server_name = parts.next()?; + let tool_name: String = parts.collect::>().join(MCP_TOOL_NAME_DELIMITER); + if tool_name.is_empty() { + return None; + } + Some((server_name.to_string(), tool_name)) +} + +pub fn group_tools_by_server( + tools: &HashMap, +) -> HashMap> { + let mut grouped = HashMap::new(); + for (qualified_name, tool) in tools { + if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) { + grouped + .entry(server_name) + .or_insert_with(HashMap::new) + .insert(tool_name, tool.clone()); + } + } + grouped +} + +pub(crate) async fn collect_mcp_snapshot_from_manager( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, +) -> McpListToolsResponseEvent { + let (tools, resources, resource_templates) = tokio::join!( + mcp_connection_manager.list_all_tools(), + mcp_connection_manager.list_all_resources(), + mcp_connection_manager.list_all_resource_templates(), + ); + + let auth_statuses = auth_status_entries + .iter() + .map(|(name, entry)| (name.clone(), entry.auth_status)) + .collect(); + + McpListToolsResponseEvent { + tools: tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + resources, + resource_templates, + auth_statuses, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ToolInputSchema; + use pretty_assertions::assert_eq; + + fn make_tool(name: &str) -> McpTool { + McpTool { + annotations: None, + description: None, + input_schema: ToolInputSchema { + properties: None, + required: None, + r#type: "object".to_string(), + }, + name: name.to_string(), + output_schema: None, + title: None, + } + } + + #[test] + fn split_qualified_tool_name_returns_server_and_tool() { + assert_eq!( + split_qualified_tool_name("mcp__alpha__do_thing"), + Some(("alpha".to_string(), "do_thing".to_string())) + ); + } + + #[test] + fn split_qualified_tool_name_rejects_invalid_names() { + assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); + assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); + } + + #[test] + fn group_tools_by_server_strips_prefix_and_groups() { + let mut tools = HashMap::new(); + tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); + tools.insert( + "mcp__alpha__nested__op".to_string(), + make_tool("nested__op"), + ); + tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); + + let mut expected_alpha = HashMap::new(); + expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); + expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); + + let mut expected_beta = HashMap::new(); + expected_beta.insert("do_other".to_string(), make_tool("do_other")); + + let mut expected = HashMap::new(); + expected.insert("alpha".to_string(), expected_alpha); + expected.insert("beta".to_string(), expected_beta); + + assert_eq!(group_tools_by_server(&tools), expected); + } +} diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index e46dd9306754..ecc6851336da 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/model_family.rs b/codex-rs/core/src/model_family.rs index 5dea1c016803..0417f13b1244 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 000000000000..13ee2e06058c --- /dev/null +++ b/codex-rs/core/src/openai_models/mod.rs @@ -0,0 +1,2 @@ +pub mod model_presets; +pub mod models_manager; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs similarity index 67% rename from codex-rs/common/src/model_presets.rs rename to codex-rs/core/src/openai_models/model_presets.rs index a031f23b1d89..3d46c695cc61 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,174 +40,176 @@ 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, }, ] }); -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 { @@ -256,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/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs new file mode 100644 index 000000000000..1d57f1e6937d --- /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/project_doc.rs b/codex-rs/core/src/project_doc.rs index ee3148e7e7b1..43a0034801a5 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/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 719e3be1f0b4..225825c93e24 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/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index 824e02803390..8ccfa6e824e0 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -53,6 +53,7 @@ (sysctl-name "hw.physicalcpu_max") (sysctl-name "hw.tbfrequency_compat") (sysctl-name "hw.vectorunit") + (sysctl-name "kern.argmax") (sysctl-name "kern.hostname") (sysctl-name "kern.maxfilesperproc") (sysctl-name "kern.maxproc") @@ -72,7 +73,8 @@ (sysctl-name-prefix "net.routetable.") ) -; Allow Java to set CPU type grade when required +; Allow Java to read some CPU info. This is misclassified as a "write" because +; userspace passes a memory buffer to the sysctl, but conceptually it is a read. (allow sysctl-write (sysctl-name "kern.grade_cputype")) @@ -86,10 +88,17 @@ (global-name "com.apple.system.opendirectoryd.libinfo") ) -; Added on top of Chrome profile ; Needed for python multiprocessing on MacOS for the SemLock (allow ipc-posix-sem) (allow mach-lookup (global-name "com.apple.PowerManagement.control") ) + +; allow openpty() +(allow pseudo-tty) +(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+") + (extension "com.apple.sandbox.pty"))) diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core/src/skills/render.rs index d547e21c2836..b6645654591f 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/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 893c0c476a10..293116c167d3 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 diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 710334c80682..a6e4167ade21 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 72c02cdb9994..d37ad4d3fc35 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, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e074d2975502..e0e06757b504 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 000000000000..9303820163fc --- /dev/null +++ b/codex-rs/core/tests/suite/list_models.rs @@ -0,0 +1,166 @@ +use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use pretty_assertions::assert_eq; + +#[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 expected_models = expected_models_for_api_key(); + assert_eq!(expected_models, models); + + Ok(()) +} + +#[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 expected_models = expected_models_for_chatgpt(); + assert_eq!(expected_models, 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 b877663614b8..35d4eb52a4dc 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 a186c13ef33c..f67196312fcf 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 f4455fd022ad..0c908e35be67 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/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 53175fca1146..52150b05118d 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -159,23 +159,18 @@ async fn read_only_forbids_all_writes() { .await; } -/// Verify that user lookups via `pwd.getpwuid(os.getuid())` work under the -/// seatbelt sandbox. Prior to allowing the necessary mach‑lookup for -/// OpenDirectory libinfo, this would fail with `KeyError: getpwuid(): uid not found`. #[tokio::test] -async fn python_getpwuid_works_under_seatbelt() { +async fn openpty_works_under_seatbelt() { if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test."); return; } - // For local dev. if which::which("python3").is_err() { eprintln!("python3 not found in PATH, skipping test."); return; } - // ReadOnly is sufficient here since we are only exercising user lookup. let policy = SandboxPolicy::ReadOnly; let command_cwd = std::env::current_dir().expect("getcwd"); let sandbox_cwd = command_cwd.clone(); @@ -184,8 +179,12 @@ async fn python_getpwuid_works_under_seatbelt() { vec![ "python3".to_string(), "-c".to_string(), - // Print the passwd struct; success implies lookup worked. - "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), + r#"import os + +master, slave = os.openpty() +os.write(slave, b"ping") +assert os.read(master, 4) == b"ping""# + .to_string(), ], command_cwd, &policy, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 73a7f0d5bd93..f0faa8b4383e 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -109,6 +109,45 @@ if __name__ == '__main__': assert!(status.success(), "python exited with {status:?}"); } +#[tokio::test] +async fn python_getpwuid_works_under_sandbox() { + core_test_support::skip_if_sandbox!(); + + if std::process::Command::new("python3") + .arg("--version") + .status() + .is_err() + { + eprintln!("python3 not found in PATH, skipping test."); + return; + } + + let policy = SandboxPolicy::ReadOnly; + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); + + let mut child = spawn_command_under_sandbox( + vec![ + "python3".to_string(), + "-c".to_string(), + "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), + ], + command_cwd, + &policy, + sandbox_cwd.as_path(), + StdioPolicy::RedirectForShellTool, + HashMap::new(), + ) + .await + .expect("should be able to spawn python under sandbox"); + + let status = child + .wait() + .await + .expect("should be able to wait for child process"); + assert!(status.success(), "python exited with {status:?}"); +} + #[tokio::test] async fn sandbox_distinguishes_command_and_policy_cwds() { core_test_support::skip_if_sandbox!(); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 9e9d07930606..83ac25fdfd4b 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 b6bc07e79f78..c300f3fb8278 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 2ee6d39746ce..a98ec4e2b2ff 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 08ea7533473a..0d6a0594fc74 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 000000000000..b99c3bbde169 --- /dev/null +++ b/codex-rs/protocol/src/openai_models.rs @@ -0,0 +1,75 @@ +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, +} + +/// 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 347cc119ff9a..99d2ec70d33f 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,7 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; +use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use crate::user_input::UserInput; @@ -208,6 +208,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 diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index be4f5aead70b..248205c42781 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 } @@ -110,3 +112,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 2ba7e4d8719a..2367bbd58222 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -21,25 +21,26 @@ 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; 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::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::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; 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; @@ -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, @@ -231,6 +239,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 { @@ -252,23 +262,28 @@ impl App { 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 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() { @@ -284,6 +299,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 { @@ -296,7 +317,10 @@ 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, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -319,7 +343,10 @@ 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, }; ChatWidget::new_from_existing( init, @@ -336,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(), @@ -354,6 +381,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. @@ -472,7 +500,10 @@ 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, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -485,6 +516,76 @@ 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(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + skills: self.skills.clone(), + is_first_run: false, + }; + 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 { @@ -1075,6 +1176,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, } } @@ -1112,34 +1214,48 @@ mod tests { 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)); + 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() )); } @@ -1148,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 b161867e445c..2f59872bced6 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -346,7 +346,10 @@ 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, }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cf494f57d6c7..3a199593bbba 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)] @@ -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 f78e544ea37b..4529b665663a 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_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; } - left_at.or(right_at) + 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(); @@ -2376,6 +2563,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/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d7501cebbcc2..39bbfbd1822c 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 b4255fd97914..a0425c92d7c8 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 000000000000..74c1b137ca1b --- /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/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 000000000000..df8ea36e6389 --- /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/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 03de3368116d..2fd415c7f65a 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) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68721784459d..f956ef5c8ab6 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; @@ -55,6 +56,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; @@ -122,15 +124,14 @@ 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::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; @@ -255,7 +256,10 @@ 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, } #[derive(Default)] @@ -273,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, @@ -1229,7 +1234,10 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, + skills, + is_first_run, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1247,10 +1255,12 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), 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(), @@ -1274,7 +1284,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, @@ -1304,7 +1314,10 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, + skills, + .. } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1324,10 +1337,12 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), 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(), @@ -1351,7 +1366,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, @@ -1479,6 +1494,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() { @@ -1539,6 +1557,9 @@ impl ChatWidget { SlashCommand::Mention => { self.insert_str("@"); } + SlashCommand::Skills => { + self.insert_str("$"); + } SlashCommand::Status => { self.add_status_output(); } @@ -2010,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 { @@ -2068,7 +2090,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![ @@ -2116,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() { @@ -2204,9 +2231,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 890e8bbe1d20..419dab2c8720 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; @@ -354,7 +354,10 @@ 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, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -379,6 +382,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 { @@ -387,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, @@ -422,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, @@ -878,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(); @@ -1184,6 +1205,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(); @@ -1738,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); @@ -1755,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); @@ -1772,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); @@ -1795,17 +1819,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, @@ -1860,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")); diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3e434138d4d6..a38cf5a84c8e 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 @@ -451,26 +456,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 +602,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}", + ); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 02ab0d243be2..c4fd31f54811 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,7 @@ 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; @@ -30,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; @@ -559,6 +560,34 @@ 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: 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)), + ); + lines + } +} + #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); @@ -586,15 +615,16 @@ pub(crate) fn new_session_info( reasoning_effort, .. } = event; - SessionInfoCell(if is_first_event { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model, - reasoning_effort, - config.cwd.clone(), - crate::version::CODEX_CLI_VERSION, - ); + // 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:" @@ -628,24 +658,24 @@ pub(crate) fn new_session_info( ]), ]; - CompositeHistoryCell { - parts: vec![ - Box::new(header), - Box::new(PlainHistoryCell { lines: help_lines }), - ], - } - } else if config.model == model { - CompositeHistoryCell { parts: vec![] } + parts.push(Box::new(PlainHistoryCell { lines: help_lines })); } else { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), - format!("used: {model}").into(), - ]; - CompositeHistoryCell { - parts: vec![Box::new(PlainHistoryCell { lines })], + if config.show_tooltips + && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) + { + parts.push(Box::new(tooltips)); } - }) + if config.model != model { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {}", config.model).into(), + format!("used: {model}").into(), + ]; + parts.push(Box::new(PlainHistoryCell { lines })); + } + } + + SessionInfoCell(CompositeHistoryCell { parts }) } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 15e9ccb92b76..0aa422cc6149 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -75,6 +75,7 @@ mod streaming; mod style; mod terminal_palette; mod text_formatting; +mod tooltips; mod tui; mod ui_consts; pub mod update_action; @@ -505,6 +506,7 @@ async fn run_ratatui_app( images, resume_selection, feedback, + should_show_trust_screen, // Proxy to: is it a first run in this directory? ) .await; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 283007e0289f..1f93fd9a4fd7 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/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70efe..b5f7b963cc4a 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( diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1cc9624ec31e..f2c6a3269ddb 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 969d279b07b3..e0c676812c8f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,8 +14,10 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Skills, Review, New, + Resume, Init, Compact, Undo, @@ -40,10 +42,12 @@ 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)", 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", @@ -64,6 +68,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Resume | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Undo @@ -73,6 +78,7 @@ impl SlashCommand { | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention + | SlashCommand::Skills | SlashCommand::Status | SlashCommand::Mcp | SlashCommand::Feedback 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 000000000000..79a169a06d37 --- /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/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index ae379aae6733..0709e366d0f7 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; diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs new file mode 100644 index 000000000000..eb419c2ea33f --- /dev/null +++ b/codex-rs/tui/src/tooltips.rs @@ -0,0 +1,49 @@ +use lazy_static::lazy_static; +use rand::Rng; + +const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); + +lazy_static! { + static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); +} + +pub(crate) fn random_tooltip() -> Option<&'static str> { + let mut rng = rand::rng(); + pick_tooltip(&mut rng) +} + +fn pick_tooltip(rng: &mut R) -> Option<&'static str> { + if TOOLTIPS.is_empty() { + None + } else { + TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand::rngs::StdRng; + + #[test] + fn random_tooltip_returns_some_tip_when_available() { + let mut rng = StdRng::seed_from_u64(42); + assert!(pick_tooltip(&mut rng).is_some()); + } + + #[test] + fn random_tooltip_is_reproducible_with_seed() { + let expected = { + let mut rng = StdRng::seed_from_u64(7); + pick_tooltip(&mut rng) + }; + + let mut rng = StdRng::seed_from_u64(7); + assert_eq!(expected, pick_tooltip(&mut rng)); + } +} diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt new file mode 100644 index 000000000000..09167eb4fcb6 --- /dev/null +++ b/codex-rs/tui/tooltips.txt @@ -0,0 +1,11 @@ +Use /compact when the conversation gets long to summarize history and free up context. +Start a fresh idea with /new; the previous session stays available in history. +If a turn went sideways, /undo asks Codex to revert the last changes. +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. +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 diff --git a/codex-rs/utils/git/src/ghost_commits.rs b/codex-rs/utils/git/src/ghost_commits.rs index 6a3eec4894fe..8544525f0fa2 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. diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4c1a2447646b..6961461d4263 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 |