Previously, `CodexAuth` was defined as follows:d550fbf41a/codex-rs/core/src/auth.rs (L39-L46)But if you looked at its constructors, we had creation for `AuthMode::ApiKey` where `storage` was built using a nonsensical path (`PathBuf::new()`) and `auth_dot_json` was `None`:d550fbf41a/codex-rs/core/src/auth.rs (L212-L220)By comparison, when `AuthMode::ChatGPT` was used, `api_key` was always `None`:d550fbf41a/codex-rs/core/src/auth.rs (L665-L671)https://github.com/openai/codex/pull/10012 took things further because it introduced a new `ChatgptAuthTokens` variant to `AuthMode`, which is important in when invoking `account/login/start` via the app server, but most logic _internal_ to the app server should just reason about two `AuthMode` variants: `ApiKey` and `ChatGPT`. This PR tries to clean things up as follows: - `LoginAccountParams` and `AuthMode` in `codex-rs/app-server-protocol/` both continue to have the `ChatgptAuthTokens` variant, though it is used exclusively for the on-the-wire messaging. - `codex-rs/core/src/auth.rs` now has its own `AuthMode` enum, which only has two variants: `ApiKey` and `ChatGPT`. - `CodexAuth` has been changed from a struct to an enum. It is a disjoint union where each variant (`ApiKey`, `ChatGpt`, and `ChatGptAuthTokens`) have only the associated fields that make sense for that variant. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/10208). * #10224 * __->__ #10208
6140 lines
232 KiB
Rust
6140 lines
232 KiB
Rust
//! The main Codex TUI chat surface.
|
||
//!
|
||
//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering
|
||
//! for both the main viewport and overlay UIs.
|
||
//!
|
||
//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active
|
||
//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a
|
||
//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a
|
||
//! cached, render-only live tail derived from the current active cell so in-flight tool calls are
|
||
//! visible immediately.
|
||
//!
|
||
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
|
||
//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The
|
||
//! cache key is designed to change when the active cell mutates in place or when its transcript
|
||
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
|
||
//! every draw.
|
||
//!
|
||
//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt
|
||
//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn
|
||
//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked
|
||
//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via
|
||
//! `update_task_running_state`.
|
||
use std::collections::HashMap;
|
||
use std::collections::HashSet;
|
||
use std::collections::VecDeque;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
use std::time::Instant;
|
||
|
||
use crate::version::CODEX_CLI_VERSION;
|
||
use codex_backend_client::Client as BackendClient;
|
||
use codex_chatgpt::connectors;
|
||
use codex_core::config::Config;
|
||
use codex_core::config::ConstraintResult;
|
||
use codex_core::config::types::Notifications;
|
||
use codex_core::features::FEATURES;
|
||
use codex_core::features::Feature;
|
||
use codex_core::git_info::current_branch_name;
|
||
use codex_core::git_info::local_git_branches;
|
||
use codex_core::models_manager::manager::ModelsManager;
|
||
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||
use codex_core::protocol::AgentMessageEvent;
|
||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||
use codex_core::protocol::AgentReasoningEvent;
|
||
use codex_core::protocol::AgentReasoningRawContentDeltaEvent;
|
||
use codex_core::protocol::AgentReasoningRawContentEvent;
|
||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||
use codex_core::protocol::BackgroundEventEvent;
|
||
use codex_core::protocol::CodexErrorInfo;
|
||
use codex_core::protocol::CreditsSnapshot;
|
||
use codex_core::protocol::DeprecationNoticeEvent;
|
||
use codex_core::protocol::ErrorEvent;
|
||
use codex_core::protocol::Event;
|
||
use codex_core::protocol::EventMsg;
|
||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||
use codex_core::protocol::ExecCommandBeginEvent;
|
||
use codex_core::protocol::ExecCommandEndEvent;
|
||
use codex_core::protocol::ExecCommandOutputDeltaEvent;
|
||
use codex_core::protocol::ExecCommandSource;
|
||
use codex_core::protocol::ExitedReviewModeEvent;
|
||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||
use codex_core::protocol::ListSkillsResponseEvent;
|
||
use codex_core::protocol::McpListToolsResponseEvent;
|
||
use codex_core::protocol::McpStartupCompleteEvent;
|
||
use codex_core::protocol::McpStartupStatus;
|
||
use codex_core::protocol::McpStartupUpdateEvent;
|
||
use codex_core::protocol::McpToolCallBeginEvent;
|
||
use codex_core::protocol::McpToolCallEndEvent;
|
||
use codex_core::protocol::Op;
|
||
use codex_core::protocol::PatchApplyBeginEvent;
|
||
use codex_core::protocol::RateLimitSnapshot;
|
||
use codex_core::protocol::ReviewRequest;
|
||
use codex_core::protocol::ReviewTarget;
|
||
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||
use codex_core::protocol::StreamErrorEvent;
|
||
use codex_core::protocol::TerminalInteractionEvent;
|
||
use codex_core::protocol::TokenUsage;
|
||
use codex_core::protocol::TokenUsageInfo;
|
||
use codex_core::protocol::TurnAbortReason;
|
||
use codex_core::protocol::TurnCompleteEvent;
|
||
use codex_core::protocol::TurnDiffEvent;
|
||
use codex_core::protocol::UndoCompletedEvent;
|
||
use codex_core::protocol::UndoStartedEvent;
|
||
use codex_core::protocol::UserMessageEvent;
|
||
use codex_core::protocol::ViewImageToolCallEvent;
|
||
use codex_core::protocol::WarningEvent;
|
||
use codex_core::protocol::WebSearchBeginEvent;
|
||
use codex_core::protocol::WebSearchEndEvent;
|
||
use codex_core::skills::model::SkillMetadata;
|
||
#[cfg(target_os = "windows")]
|
||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||
use codex_otel::OtelManager;
|
||
use codex_protocol::ThreadId;
|
||
use codex_protocol::account::PlanType;
|
||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||
use codex_protocol::config_types::CollaborationMode;
|
||
use codex_protocol::config_types::CollaborationModeMask;
|
||
use codex_protocol::config_types::ModeKind;
|
||
use codex_protocol::config_types::Personality;
|
||
use codex_protocol::config_types::Settings;
|
||
#[cfg(target_os = "windows")]
|
||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||
use codex_protocol::models::local_image_label_text;
|
||
use codex_protocol::parse_command::ParsedCommand;
|
||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||
use codex_protocol::user_input::TextElement;
|
||
use codex_protocol::user_input::UserInput;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::event::KeyModifiers;
|
||
use rand::Rng;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Color;
|
||
use ratatui::style::Modifier;
|
||
use ratatui::style::Style;
|
||
use ratatui::style::Stylize;
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::Wrap;
|
||
use tokio::sync::mpsc::UnboundedSender;
|
||
use tokio::task::JoinHandle;
|
||
use tracing::debug;
|
||
|
||
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
|
||
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
|
||
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
|
||
|
||
use crate::app_event::AppEvent;
|
||
use crate::app_event::ConnectorsSnapshot;
|
||
use crate::app_event::ExitMode;
|
||
#[cfg(target_os = "windows")]
|
||
use crate::app_event::WindowsSandboxEnableMode;
|
||
use crate::app_event::WindowsSandboxFallbackReason;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::bottom_pane::ApprovalRequest;
|
||
use crate::bottom_pane::BottomPane;
|
||
use crate::bottom_pane::BottomPaneParams;
|
||
use crate::bottom_pane::CancellationEvent;
|
||
use crate::bottom_pane::CollaborationModeIndicator;
|
||
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||
use crate::bottom_pane::ExperimentalFeatureItem;
|
||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||
use crate::bottom_pane::FeedbackAudience;
|
||
use crate::bottom_pane::InputResult;
|
||
use crate::bottom_pane::LocalImageAttachment;
|
||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||
use crate::bottom_pane::SelectionAction;
|
||
use crate::bottom_pane::SelectionItem;
|
||
use crate::bottom_pane::SelectionViewParams;
|
||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||
use crate::collab;
|
||
use crate::collaboration_modes;
|
||
use crate::diff_render::display_path_for;
|
||
use crate::exec_cell::CommandOutput;
|
||
use crate::exec_cell::ExecCell;
|
||
use crate::exec_cell::new_active_exec_command;
|
||
use crate::exec_command::strip_bash_lc_and_escape;
|
||
use crate::get_git_diff::get_git_diff;
|
||
use crate::history_cell;
|
||
use crate::history_cell::AgentMessageCell;
|
||
use crate::history_cell::HistoryCell;
|
||
use crate::history_cell::McpToolCallCell;
|
||
use crate::history_cell::PlainHistoryCell;
|
||
use crate::history_cell::WebSearchCell;
|
||
use crate::key_hint;
|
||
use crate::key_hint::KeyBinding;
|
||
use crate::markdown::append_markdown;
|
||
use crate::render::Insets;
|
||
use crate::render::renderable::ColumnRenderable;
|
||
use crate::render::renderable::FlexRenderable;
|
||
use crate::render::renderable::Renderable;
|
||
use crate::render::renderable::RenderableExt;
|
||
use crate::render::renderable::RenderableItem;
|
||
use crate::slash_command::SlashCommand;
|
||
use crate::status::RateLimitSnapshotDisplay;
|
||
use crate::text_formatting::truncate_text;
|
||
use crate::tui::FrameRequester;
|
||
mod interrupts;
|
||
use self::interrupts::InterruptManager;
|
||
mod agent;
|
||
use self::agent::spawn_agent;
|
||
use self::agent::spawn_agent_from_existing;
|
||
pub(crate) use self::agent::spawn_op_forwarder;
|
||
mod session_header;
|
||
use self::session_header::SessionHeader;
|
||
mod skills;
|
||
use self::skills::collect_tool_mentions;
|
||
use self::skills::find_app_mentions;
|
||
use self::skills::find_skill_mentions_with_tool_mentions;
|
||
use crate::streaming::controller::StreamController;
|
||
|
||
use chrono::Local;
|
||
use codex_common::approval_presets::ApprovalPreset;
|
||
use codex_common::approval_presets::builtin_approval_presets;
|
||
use codex_core::AuthManager;
|
||
use codex_core::CodexAuth;
|
||
use codex_core::ThreadManager;
|
||
use codex_core::protocol::AskForApproval;
|
||
use codex_core::protocol::SandboxPolicy;
|
||
use codex_file_search::FileMatch;
|
||
use codex_protocol::openai_models::ModelPreset;
|
||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||
use strum::IntoEnumIterator;
|
||
|
||
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
|
||
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
|
||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||
// Track information about an in-flight exec command.
|
||
struct RunningCommand {
|
||
command: Vec<String>,
|
||
parsed_cmd: Vec<ParsedCommand>,
|
||
source: ExecCommandSource,
|
||
}
|
||
|
||
struct UnifiedExecProcessSummary {
|
||
key: String,
|
||
call_id: String,
|
||
command_display: String,
|
||
recent_chunks: Vec<String>,
|
||
}
|
||
|
||
struct UnifiedExecWaitState {
|
||
command_display: String,
|
||
}
|
||
|
||
impl UnifiedExecWaitState {
|
||
fn new(command_display: String) -> Self {
|
||
Self { command_display }
|
||
}
|
||
|
||
fn is_duplicate(&self, command_display: &str) -> bool {
|
||
self.command_display == command_display
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct UnifiedExecWaitStreak {
|
||
process_id: String,
|
||
command_display: Option<String>,
|
||
}
|
||
|
||
impl UnifiedExecWaitStreak {
|
||
fn new(process_id: String, command_display: Option<String>) -> Self {
|
||
Self {
|
||
process_id,
|
||
command_display: command_display.filter(|display| !display.is_empty()),
|
||
}
|
||
}
|
||
|
||
fn update_command_display(&mut self, command_display: Option<String>) {
|
||
if self.command_display.is_some() {
|
||
return;
|
||
}
|
||
self.command_display = command_display.filter(|display| !display.is_empty());
|
||
}
|
||
}
|
||
|
||
fn is_unified_exec_source(source: ExecCommandSource) -> bool {
|
||
matches!(
|
||
source,
|
||
ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction
|
||
)
|
||
}
|
||
|
||
fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
|
||
!parsed_cmd.is_empty()
|
||
&& parsed_cmd
|
||
.iter()
|
||
.all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. }))
|
||
}
|
||
|
||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||
const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini";
|
||
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||
|
||
#[derive(Default)]
|
||
struct RateLimitWarningState {
|
||
secondary_index: usize,
|
||
primary_index: usize,
|
||
}
|
||
|
||
impl RateLimitWarningState {
|
||
fn take_warnings(
|
||
&mut self,
|
||
secondary_used_percent: Option<f64>,
|
||
secondary_window_minutes: Option<i64>,
|
||
primary_used_percent: Option<f64>,
|
||
primary_window_minutes: Option<i64>,
|
||
) -> Vec<String> {
|
||
let reached_secondary_cap =
|
||
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
|
||
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
|
||
if reached_secondary_cap || reached_primary_cap {
|
||
return Vec::new();
|
||
}
|
||
|
||
let mut warnings = Vec::new();
|
||
|
||
if let Some(secondary_used_percent) = secondary_used_percent {
|
||
let mut highest_secondary: Option<f64> = None;
|
||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||
{
|
||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||
self.secondary_index += 1;
|
||
}
|
||
if let Some(threshold) = highest_secondary {
|
||
let limit_label = secondary_window_minutes
|
||
.map(get_limits_duration)
|
||
.unwrap_or_else(|| "weekly".to_string());
|
||
let remaining_percent = 100.0 - threshold;
|
||
warnings.push(format!(
|
||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||
));
|
||
}
|
||
}
|
||
|
||
if let Some(primary_used_percent) = primary_used_percent {
|
||
let mut highest_primary: Option<f64> = None;
|
||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||
{
|
||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||
self.primary_index += 1;
|
||
}
|
||
if let Some(threshold) = highest_primary {
|
||
let limit_label = primary_window_minutes
|
||
.map(get_limits_duration)
|
||
.unwrap_or_else(|| "5h".to_string());
|
||
let remaining_percent = 100.0 - threshold;
|
||
warnings.push(format!(
|
||
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
||
));
|
||
}
|
||
}
|
||
|
||
warnings
|
||
}
|
||
}
|
||
|
||
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
||
const MINUTES_PER_HOUR: i64 = 60;
|
||
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
|
||
const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;
|
||
const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;
|
||
const ROUNDING_BIAS_MINUTES: i64 = 3;
|
||
|
||
let windows_minutes = windows_minutes.max(0);
|
||
|
||
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
|
||
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
|
||
format!("{hours}h")
|
||
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||
"weekly".to_string()
|
||
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
|
||
"monthly".to_string()
|
||
} else {
|
||
"annual".to_string()
|
||
}
|
||
}
|
||
|
||
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||
pub(crate) struct ChatWidgetInit {
|
||
pub(crate) config: Config,
|
||
pub(crate) frame_requester: FrameRequester,
|
||
pub(crate) app_event_tx: AppEventSender,
|
||
pub(crate) initial_user_message: Option<UserMessage>,
|
||
pub(crate) enhanced_keys_supported: bool,
|
||
pub(crate) auth_manager: Arc<AuthManager>,
|
||
pub(crate) models_manager: Arc<ModelsManager>,
|
||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||
pub(crate) is_first_run: bool,
|
||
pub(crate) feedback_audience: FeedbackAudience,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) otel_manager: OtelManager,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
enum RateLimitSwitchPromptState {
|
||
#[default]
|
||
Idle,
|
||
Pending,
|
||
Shown,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
enum ConnectorsCacheState {
|
||
#[default]
|
||
Uninitialized,
|
||
Loading,
|
||
Ready(ConnectorsSnapshot),
|
||
Failed(String),
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
enum RateLimitErrorKind {
|
||
ModelCap {
|
||
model: String,
|
||
reset_after_seconds: Option<u64>,
|
||
},
|
||
UsageLimit,
|
||
Generic,
|
||
}
|
||
|
||
fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option<RateLimitErrorKind> {
|
||
match info {
|
||
CodexErrorInfo::ModelCap {
|
||
model,
|
||
reset_after_seconds,
|
||
} => Some(RateLimitErrorKind::ModelCap {
|
||
model: model.clone(),
|
||
reset_after_seconds: *reset_after_seconds,
|
||
}),
|
||
CodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
|
||
CodexErrorInfo::ResponseTooManyFailedAttempts {
|
||
http_status_code: Some(429),
|
||
} => Some(RateLimitErrorKind::Generic),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub(crate) enum ExternalEditorState {
|
||
#[default]
|
||
Closed,
|
||
Requested,
|
||
Active,
|
||
}
|
||
|
||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||
///
|
||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
|
||
/// intent (`Op` submissions and `AppEvent` requests).
|
||
///
|
||
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
|
||
/// and by sending requests back to codex-core.
|
||
///
|
||
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
|
||
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
|
||
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
|
||
pub(crate) struct ChatWidget {
|
||
app_event_tx: AppEventSender,
|
||
codex_op_tx: UnboundedSender<Op>,
|
||
bottom_pane: BottomPane,
|
||
active_cell: Option<Box<dyn HistoryCell>>,
|
||
/// Monotonic-ish counter used to invalidate transcript overlay caching.
|
||
///
|
||
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
|
||
/// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer
|
||
/// identity alone is not a good cache key.
|
||
///
|
||
/// Callers bump this whenever the active cell's transcript output could change without
|
||
/// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision
|
||
/// where the overlay may briefly treat new tail content as already cached.
|
||
active_cell_revision: u64,
|
||
config: Config,
|
||
/// The unmasked collaboration mode settings (always Custom mode).
|
||
///
|
||
/// Masks are applied on top of this base mode to derive the effective mode.
|
||
current_collaboration_mode: CollaborationMode,
|
||
/// The currently active collaboration mask, if any.
|
||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||
auth_manager: Arc<AuthManager>,
|
||
models_manager: Arc<ModelsManager>,
|
||
otel_manager: OtelManager,
|
||
session_header: SessionHeader,
|
||
initial_user_message: Option<UserMessage>,
|
||
token_info: Option<TokenUsageInfo>,
|
||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||
plan_type: Option<PlanType>,
|
||
rate_limit_warnings: RateLimitWarningState,
|
||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||
rate_limit_poller: Option<JoinHandle<()>>,
|
||
// Stream lifecycle controller
|
||
stream_controller: Option<StreamController>,
|
||
running_commands: HashMap<String, RunningCommand>,
|
||
suppressed_exec_calls: HashSet<String>,
|
||
skills_all: Vec<ProtocolSkillMetadata>,
|
||
skills_initial_state: Option<HashMap<PathBuf, bool>>,
|
||
last_unified_wait: Option<UnifiedExecWaitState>,
|
||
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
|
||
task_complete_pending: bool,
|
||
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
|
||
/// Tracks whether codex-core currently considers an agent turn to be in progress.
|
||
///
|
||
/// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion)
|
||
/// can update the status header without accidentally clearing the spinner for an active turn.
|
||
agent_turn_running: bool,
|
||
/// Tracks per-server MCP startup state while startup is in progress.
|
||
///
|
||
/// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the
|
||
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
|
||
/// currently executing.
|
||
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
|
||
connectors_cache: ConnectorsCacheState,
|
||
// Queue of interruptive UI events deferred during an active write cycle
|
||
interrupts: InterruptManager,
|
||
// Accumulates the current reasoning block text to extract a header
|
||
reasoning_buffer: String,
|
||
// Accumulates full reasoning content for transcript-only recording
|
||
full_reasoning_buffer: String,
|
||
// Current status header shown in the status indicator.
|
||
current_status_header: String,
|
||
// Previous status header to restore after a transient stream retry.
|
||
retry_status_header: Option<String>,
|
||
thread_id: Option<ThreadId>,
|
||
thread_name: Option<String>,
|
||
forked_from: Option<ThreadId>,
|
||
frame_requester: FrameRequester,
|
||
// Whether to include the initial welcome banner on session configured
|
||
show_welcome_banner: bool,
|
||
// When resuming an existing session (selected via resume picker), avoid an
|
||
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
|
||
suppress_session_configured_redraw: bool,
|
||
// User messages queued while a turn is in progress
|
||
queued_user_messages: VecDeque<UserMessage>,
|
||
// Pending notification to show when unfocused on next Draw
|
||
pending_notification: Option<Notification>,
|
||
/// When `Some`, the user has pressed a quit shortcut and the second press
|
||
/// must occur before `quit_shortcut_expires_at`.
|
||
quit_shortcut_expires_at: Option<Instant>,
|
||
/// Tracks which quit shortcut key was pressed first.
|
||
///
|
||
/// We require the second press to match this key so `Ctrl+C` followed by
|
||
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
|
||
quit_shortcut_key: Option<KeyBinding>,
|
||
// Simple review mode flag; used to adjust layout and banners.
|
||
is_review_mode: bool,
|
||
// Snapshot of token usage to restore after review mode exits.
|
||
pre_review_token_info: Option<Option<TokenUsageInfo>>,
|
||
// Whether the next streamed assistant content should be preceded by a final message separator.
|
||
//
|
||
// This is set whenever we insert a visible history cell that conceptually belongs to a turn.
|
||
// The separator itself is only rendered if the turn recorded "work" activity (see
|
||
// `had_work_activity`).
|
||
needs_final_message_separator: bool,
|
||
// Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications).
|
||
//
|
||
// This gates rendering of the "Worked for …" separator so purely conversational turns don't
|
||
// show an empty divider. It is reset when the separator is emitted.
|
||
had_work_activity: bool,
|
||
// Whether the current turn emitted a plan update.
|
||
saw_plan_update_this_turn: bool,
|
||
// Status-indicator elapsed seconds captured at the last emitted final-message separator.
|
||
//
|
||
// This lets the separator show per-chunk work time (since the previous separator) rather than
|
||
// the total task-running time reported by the status indicator.
|
||
last_separator_elapsed_secs: Option<u64>,
|
||
|
||
last_rendered_width: std::cell::Cell<Option<usize>>,
|
||
// Feedback sink for /feedback
|
||
feedback: codex_feedback::CodexFeedback,
|
||
feedback_audience: FeedbackAudience,
|
||
// Current session rollout path (if known)
|
||
current_rollout_path: Option<PathBuf>,
|
||
external_editor_state: ExternalEditorState,
|
||
}
|
||
|
||
/// Snapshot of active-cell state that affects transcript overlay rendering.
|
||
///
|
||
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
|
||
/// it cheaply decide when to recompute that tail as the active cell evolves.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) struct ActiveCellTranscriptKey {
|
||
/// Cache-busting revision for in-place updates.
|
||
///
|
||
/// Many active cells are updated incrementally while streaming (for example when exec groups
|
||
/// add output or change status), and the transcript overlay caches its live tail, so this
|
||
/// revision gives a cheap way to say "same active cell, but its transcript output is different
|
||
/// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`.
|
||
pub(crate) revision: u64,
|
||
/// Whether the active cell continues the prior stream, which affects
|
||
/// spacing between transcript blocks.
|
||
pub(crate) is_stream_continuation: bool,
|
||
/// Optional animation tick for time-dependent transcript output.
|
||
///
|
||
/// When this changes, the overlay recomputes the cached tail even if the revision and width
|
||
/// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any
|
||
/// underlying data change.
|
||
pub(crate) animation_tick: Option<u64>,
|
||
}
|
||
|
||
pub(crate) struct UserMessage {
|
||
text: String,
|
||
local_images: Vec<LocalImageAttachment>,
|
||
text_elements: Vec<TextElement>,
|
||
mention_paths: HashMap<String, String>,
|
||
}
|
||
|
||
impl From<String> for UserMessage {
|
||
fn from(text: String) -> Self {
|
||
Self {
|
||
text,
|
||
local_images: Vec::new(),
|
||
// Plain text conversion has no UI element ranges.
|
||
text_elements: Vec::new(),
|
||
mention_paths: HashMap::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<&str> for UserMessage {
|
||
fn from(text: &str) -> Self {
|
||
Self {
|
||
text: text.to_string(),
|
||
local_images: Vec::new(),
|
||
// Plain text conversion has no UI element ranges.
|
||
text_elements: Vec::new(),
|
||
mention_paths: HashMap::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn create_initial_user_message(
|
||
text: Option<String>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
text_elements: Vec<TextElement>,
|
||
) -> Option<UserMessage> {
|
||
let text = text.unwrap_or_default();
|
||
if text.is_empty() && local_image_paths.is_empty() {
|
||
None
|
||
} else {
|
||
let local_images = local_image_paths
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(idx, path)| LocalImageAttachment {
|
||
placeholder: local_image_label_text(idx + 1),
|
||
path,
|
||
})
|
||
.collect();
|
||
Some(UserMessage {
|
||
text,
|
||
local_images,
|
||
text_elements,
|
||
mention_paths: HashMap::new(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering
|
||
// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so
|
||
// the combined local_image_paths order matches the labels, even if placeholders were moved
|
||
// in the text (e.g., [Image #2] appearing before [Image #1]).
|
||
fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage {
|
||
let UserMessage {
|
||
text,
|
||
text_elements,
|
||
local_images,
|
||
mention_paths,
|
||
} = message;
|
||
if local_images.is_empty() {
|
||
return UserMessage {
|
||
text,
|
||
text_elements,
|
||
local_images,
|
||
mention_paths,
|
||
};
|
||
}
|
||
|
||
let mut mapping: HashMap<String, String> = HashMap::new();
|
||
let mut remapped_images = Vec::new();
|
||
for attachment in local_images {
|
||
let new_placeholder = local_image_label_text(*next_label);
|
||
*next_label += 1;
|
||
mapping.insert(attachment.placeholder.clone(), new_placeholder.clone());
|
||
remapped_images.push(LocalImageAttachment {
|
||
placeholder: new_placeholder,
|
||
path: attachment.path,
|
||
});
|
||
}
|
||
|
||
let mut elements = text_elements;
|
||
elements.sort_by_key(|elem| elem.byte_range.start);
|
||
|
||
let mut cursor = 0usize;
|
||
let mut rebuilt = String::new();
|
||
let mut rebuilt_elements = Vec::new();
|
||
for mut elem in elements {
|
||
let start = elem.byte_range.start.min(text.len());
|
||
let end = elem.byte_range.end.min(text.len());
|
||
if let Some(segment) = text.get(cursor..start) {
|
||
rebuilt.push_str(segment);
|
||
}
|
||
|
||
let original = text.get(start..end).unwrap_or("");
|
||
let placeholder = elem.placeholder(&text);
|
||
let replacement = placeholder
|
||
.and_then(|ph| mapping.get(ph))
|
||
.map(String::as_str)
|
||
.unwrap_or(original);
|
||
|
||
let elem_start = rebuilt.len();
|
||
rebuilt.push_str(replacement);
|
||
let elem_end = rebuilt.len();
|
||
|
||
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
||
elem.set_placeholder(Some(remapped.clone()));
|
||
}
|
||
elem.byte_range = (elem_start..elem_end).into();
|
||
rebuilt_elements.push(elem);
|
||
cursor = end;
|
||
}
|
||
if let Some(segment) = text.get(cursor..) {
|
||
rebuilt.push_str(segment);
|
||
}
|
||
|
||
UserMessage {
|
||
text: rebuilt,
|
||
local_images: remapped_images,
|
||
text_elements: rebuilt_elements,
|
||
mention_paths,
|
||
}
|
||
}
|
||
|
||
impl ChatWidget {
|
||
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
|
||
///
|
||
/// The bottom pane only has one running flag, but this module treats it as a derived state of
|
||
/// both the agent turn lifecycle and MCP startup lifecycle.
|
||
fn update_task_running_state(&mut self) {
|
||
self.bottom_pane
|
||
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
|
||
}
|
||
|
||
fn restore_reasoning_status_header(&mut self) {
|
||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||
self.set_status_header(header);
|
||
} else if self.bottom_pane.is_task_running() {
|
||
self.set_status_header(String::from("Working"));
|
||
}
|
||
}
|
||
|
||
fn flush_unified_exec_wait_streak(&mut self) {
|
||
let Some(wait) = self.unified_exec_wait_streak.take() else {
|
||
return;
|
||
};
|
||
self.needs_final_message_separator = true;
|
||
let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new());
|
||
self.app_event_tx
|
||
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||
self.restore_reasoning_status_header();
|
||
}
|
||
|
||
fn flush_answer_stream_with_separator(&mut self) {
|
||
if let Some(mut controller) = self.stream_controller.take()
|
||
&& let Some(cell) = controller.finalize()
|
||
{
|
||
self.add_boxed_history(cell);
|
||
}
|
||
}
|
||
|
||
/// Update the status indicator header and details.
|
||
///
|
||
/// Passing `None` clears any existing details.
|
||
fn set_status(&mut self, header: String, details: Option<String>) {
|
||
self.current_status_header = header.clone();
|
||
self.bottom_pane.update_status(header, details);
|
||
}
|
||
|
||
/// Convenience wrapper around [`Self::set_status`];
|
||
/// updates the status indicator header and clears any existing details.
|
||
fn set_status_header(&mut self, header: String) {
|
||
self.set_status(header, None);
|
||
}
|
||
|
||
fn restore_retry_status_header_if_present(&mut self) {
|
||
if let Some(header) = self.retry_status_header.take() {
|
||
self.set_status_header(header);
|
||
}
|
||
}
|
||
|
||
// --- Small event handlers ---
|
||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||
self.bottom_pane
|
||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||
self.set_skills(None);
|
||
self.bottom_pane.set_connectors_snapshot(None);
|
||
self.thread_id = Some(event.session_id);
|
||
self.thread_name = event.thread_name.clone();
|
||
self.forked_from = event.forked_from_id;
|
||
self.current_rollout_path = event.rollout_path.clone();
|
||
let initial_messages = event.initial_messages.clone();
|
||
let model_for_header = event.model.clone();
|
||
self.session_header.set_model(&model_for_header);
|
||
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
|
||
Some(model_for_header.clone()),
|
||
Some(event.reasoning_effort),
|
||
None,
|
||
);
|
||
self.refresh_model_display();
|
||
self.sync_personality_command_enabled();
|
||
let session_info_cell = history_cell::new_session_info(
|
||
&self.config,
|
||
&model_for_header,
|
||
event,
|
||
self.show_welcome_banner,
|
||
);
|
||
self.apply_session_info_cell(session_info_cell);
|
||
|
||
if let Some(messages) = initial_messages {
|
||
self.replay_initial_messages(messages);
|
||
}
|
||
// Ask codex-core to enumerate custom prompts for this session.
|
||
self.submit_op(Op::ListCustomPrompts);
|
||
self.submit_op(Op::ListSkills {
|
||
cwds: Vec::new(),
|
||
force_reload: true,
|
||
});
|
||
if self.connectors_enabled() {
|
||
self.prefetch_connectors();
|
||
}
|
||
if let Some(user_message) = self.initial_user_message.take() {
|
||
self.submit_user_message(user_message);
|
||
}
|
||
if !self.suppress_session_configured_redraw {
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
|
||
fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) {
|
||
if self.thread_id == Some(event.thread_id) {
|
||
self.thread_name = event.thread_name;
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
|
||
fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||
self.bottom_pane.set_skills(skills);
|
||
}
|
||
|
||
pub(crate) fn open_feedback_note(
|
||
&mut self,
|
||
category: crate::app_event::FeedbackCategory,
|
||
include_logs: bool,
|
||
) {
|
||
// Build a fresh snapshot at the time of opening the note overlay.
|
||
let snapshot = self.feedback.snapshot(self.thread_id);
|
||
let rollout = if include_logs {
|
||
self.current_rollout_path.clone()
|
||
} else {
|
||
None
|
||
};
|
||
let view = crate::bottom_pane::FeedbackNoteView::new(
|
||
category,
|
||
snapshot,
|
||
rollout,
|
||
self.app_event_tx.clone(),
|
||
include_logs,
|
||
self.feedback_audience,
|
||
);
|
||
self.bottom_pane.show_view(Box::new(view));
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn open_app_link_view(
|
||
&mut self,
|
||
title: String,
|
||
description: Option<String>,
|
||
instructions: String,
|
||
url: String,
|
||
is_installed: bool,
|
||
) {
|
||
let view = crate::bottom_pane::AppLinkView::new(
|
||
title,
|
||
description,
|
||
instructions,
|
||
url,
|
||
is_installed,
|
||
);
|
||
self.bottom_pane.show_view(Box::new(view));
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
|
||
let params = crate::bottom_pane::feedback_upload_consent_params(
|
||
self.app_event_tx.clone(),
|
||
category,
|
||
self.current_rollout_path.clone(),
|
||
);
|
||
self.bottom_pane.show_selection_view(params);
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_agent_message(&mut self, message: String) {
|
||
// If we have a stream_controller, then the final agent message is redundant and will be a
|
||
// duplicate of what has already been streamed.
|
||
if self.stream_controller.is_none() {
|
||
self.handle_streaming_delta(message);
|
||
}
|
||
self.flush_answer_stream_with_separator();
|
||
self.handle_stream_finished();
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_agent_message_delta(&mut self, delta: String) {
|
||
self.handle_streaming_delta(delta);
|
||
}
|
||
|
||
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
||
// For reasoning deltas, do not stream to history. Accumulate the
|
||
// current reasoning block and extract the first bold element
|
||
// (between **/**) as the chunk header. Show this header as status.
|
||
self.reasoning_buffer.push_str(&delta);
|
||
|
||
if self.unified_exec_wait_streak.is_some() {
|
||
// Unified exec waiting should take precedence over reasoning-derived status headers.
|
||
self.request_redraw();
|
||
return;
|
||
}
|
||
|
||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||
// Update the shimmer header to the extracted reasoning chunk header.
|
||
self.set_status_header(header);
|
||
} else {
|
||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_agent_reasoning_final(&mut self) {
|
||
// At the end of a reasoning block, record transcript-only content.
|
||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||
if !self.full_reasoning_buffer.is_empty() {
|
||
let cell =
|
||
history_cell::new_reasoning_summary_block(self.full_reasoning_buffer.clone());
|
||
self.add_boxed_history(cell);
|
||
}
|
||
self.reasoning_buffer.clear();
|
||
self.full_reasoning_buffer.clear();
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_reasoning_section_break(&mut self) {
|
||
// Start a new reasoning block for header extraction and accumulate transcript.
|
||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||
self.full_reasoning_buffer.push_str("\n\n");
|
||
self.reasoning_buffer.clear();
|
||
}
|
||
|
||
// Raw reasoning uses the same flow as summarized reasoning
|
||
|
||
fn on_task_started(&mut self) {
|
||
self.agent_turn_running = true;
|
||
self.saw_plan_update_this_turn = false;
|
||
self.bottom_pane.clear_quit_shortcut_hint();
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
self.update_task_running_state();
|
||
self.retry_status_header = None;
|
||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||
self.set_status_header(String::from("Working"));
|
||
self.full_reasoning_buffer.clear();
|
||
self.reasoning_buffer.clear();
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_task_complete(&mut self, last_agent_message: Option<String>, from_replay: bool) {
|
||
// If a stream is currently active, finalize it.
|
||
self.flush_answer_stream_with_separator();
|
||
self.flush_unified_exec_wait_streak();
|
||
// Mark task stopped and request redraw now that all content is in history.
|
||
self.agent_turn_running = false;
|
||
self.update_task_running_state();
|
||
self.running_commands.clear();
|
||
self.suppressed_exec_calls.clear();
|
||
self.last_unified_wait = None;
|
||
self.unified_exec_wait_streak = None;
|
||
self.clear_unified_exec_processes();
|
||
self.request_redraw();
|
||
|
||
if !from_replay && self.queued_user_messages.is_empty() {
|
||
self.maybe_prompt_plan_implementation(last_agent_message.as_deref());
|
||
}
|
||
// If there is a queued user message, send exactly one now to begin the next turn.
|
||
self.maybe_send_next_queued_input();
|
||
// Emit a notification when the turn completes (suppressed if focused).
|
||
self.notify(Notification::AgentTurnComplete {
|
||
response: last_agent_message.unwrap_or_default(),
|
||
});
|
||
|
||
self.maybe_show_pending_rate_limit_prompt();
|
||
}
|
||
|
||
fn maybe_prompt_plan_implementation(&mut self, last_agent_message: Option<&str>) {
|
||
if !self.collaboration_modes_enabled() {
|
||
return;
|
||
}
|
||
if !self.queued_user_messages.is_empty() {
|
||
return;
|
||
}
|
||
if self.active_mode_kind() != ModeKind::Plan {
|
||
return;
|
||
}
|
||
let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty());
|
||
if !has_message && !self.saw_plan_update_this_turn {
|
||
return;
|
||
}
|
||
if !self.bottom_pane.no_modal_or_popup_active() {
|
||
return;
|
||
}
|
||
|
||
if matches!(
|
||
self.rate_limit_switch_prompt,
|
||
RateLimitSwitchPromptState::Pending
|
||
) {
|
||
return;
|
||
}
|
||
|
||
self.open_plan_implementation_prompt();
|
||
}
|
||
|
||
fn open_plan_implementation_prompt(&mut self) {
|
||
let code_mask = collaboration_modes::code_mask(self.models_manager.as_ref());
|
||
let (implement_actions, implement_disabled_reason) = match code_mask {
|
||
Some(mask) => {
|
||
let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string();
|
||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::SubmitUserMessageWithMode {
|
||
text: user_text.clone(),
|
||
collaboration_mode: mask.clone(),
|
||
});
|
||
})];
|
||
(actions, None)
|
||
}
|
||
None => (Vec::new(), Some("Code mode unavailable".to_string())),
|
||
};
|
||
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: PLAN_IMPLEMENTATION_YES.to_string(),
|
||
description: Some("Switch to Code and start coding.".to_string()),
|
||
selected_description: None,
|
||
is_current: false,
|
||
actions: implement_actions,
|
||
disabled_reason: implement_disabled_reason,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: PLAN_IMPLEMENTATION_NO.to_string(),
|
||
description: Some("Continue planning with the model.".to_string()),
|
||
selected_description: None,
|
||
is_current: false,
|
||
actions: Vec::new(),
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some(PLAN_IMPLEMENTATION_TITLE.to_string()),
|
||
subtitle: None,
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||
match info {
|
||
Some(info) => self.apply_token_info(info),
|
||
None => {
|
||
self.bottom_pane.set_context_window(None, None);
|
||
self.token_info = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply_token_info(&mut self, info: TokenUsageInfo) {
|
||
let percent = self.context_remaining_percent(&info);
|
||
let used_tokens = self.context_used_tokens(&info, percent.is_some());
|
||
self.bottom_pane.set_context_window(percent, used_tokens);
|
||
self.token_info = Some(info);
|
||
}
|
||
|
||
fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option<i64> {
|
||
info.model_context_window.map(|window| {
|
||
info.last_token_usage
|
||
.percent_of_context_window_remaining(window)
|
||
})
|
||
}
|
||
|
||
fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option<i64> {
|
||
if percent_known {
|
||
return None;
|
||
}
|
||
|
||
Some(info.total_token_usage.tokens_in_context_window())
|
||
}
|
||
|
||
fn restore_pre_review_token_info(&mut self) {
|
||
if let Some(saved) = self.pre_review_token_info.take() {
|
||
match saved {
|
||
Some(info) => self.apply_token_info(info),
|
||
None => {
|
||
self.bottom_pane.set_context_window(None, None);
|
||
self.token_info = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||
if let Some(mut snapshot) = snapshot {
|
||
if snapshot.credits.is_none() {
|
||
snapshot.credits = self
|
||
.rate_limit_snapshot
|
||
.as_ref()
|
||
.and_then(|display| display.credits.as_ref())
|
||
.map(|credits| CreditsSnapshot {
|
||
has_credits: credits.has_credits,
|
||
unlimited: credits.unlimited,
|
||
balance: credits.balance.clone(),
|
||
});
|
||
}
|
||
|
||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||
|
||
let warnings = self.rate_limit_warnings.take_warnings(
|
||
snapshot
|
||
.secondary
|
||
.as_ref()
|
||
.map(|window| window.used_percent),
|
||
snapshot
|
||
.secondary
|
||
.as_ref()
|
||
.and_then(|window| window.window_minutes),
|
||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||
snapshot
|
||
.primary
|
||
.as_ref()
|
||
.and_then(|window| window.window_minutes),
|
||
);
|
||
|
||
let high_usage = snapshot
|
||
.secondary
|
||
.as_ref()
|
||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||
.unwrap_or(false)
|
||
|| snapshot
|
||
.primary
|
||
.as_ref()
|
||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||
.unwrap_or(false);
|
||
|
||
if high_usage
|
||
&& !self.rate_limit_switch_prompt_hidden()
|
||
&& self.current_model() != NUDGE_MODEL_SLUG
|
||
&& !matches!(
|
||
self.rate_limit_switch_prompt,
|
||
RateLimitSwitchPromptState::Shown
|
||
)
|
||
{
|
||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||
}
|
||
|
||
let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now());
|
||
self.rate_limit_snapshot = Some(display);
|
||
|
||
if !warnings.is_empty() {
|
||
for warning in warnings {
|
||
self.add_to_history(history_cell::new_warning_event(warning));
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
} else {
|
||
self.rate_limit_snapshot = None;
|
||
}
|
||
}
|
||
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
|
||
///
|
||
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
|
||
/// and should continue to drive the bottom-pane running indicator while it is in progress.
|
||
fn finalize_turn(&mut self) {
|
||
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
||
self.finalize_active_cell_as_failed();
|
||
// Reset running state and clear streaming buffers.
|
||
self.agent_turn_running = false;
|
||
self.update_task_running_state();
|
||
self.running_commands.clear();
|
||
self.suppressed_exec_calls.clear();
|
||
self.last_unified_wait = None;
|
||
self.unified_exec_wait_streak = None;
|
||
self.clear_unified_exec_processes();
|
||
self.stream_controller = None;
|
||
self.maybe_show_pending_rate_limit_prompt();
|
||
}
|
||
|
||
fn on_model_cap_error(&mut self, model: String, reset_after_seconds: Option<u64>) {
|
||
self.finalize_turn();
|
||
|
||
let mut message = format!("Model {model} is at capacity. Please try a different model.");
|
||
if let Some(seconds) = reset_after_seconds {
|
||
message.push_str(&format!(
|
||
" Try again in {}.",
|
||
format_duration_short(seconds)
|
||
));
|
||
} else {
|
||
message.push_str(" Try again later.");
|
||
}
|
||
|
||
self.add_to_history(history_cell::new_warning_event(message));
|
||
self.request_redraw();
|
||
self.maybe_send_next_queued_input();
|
||
}
|
||
|
||
fn on_error(&mut self, message: String) {
|
||
self.finalize_turn();
|
||
self.add_to_history(history_cell::new_error_event(message));
|
||
self.request_redraw();
|
||
|
||
// After an error ends the turn, try sending the next queued input.
|
||
self.maybe_send_next_queued_input();
|
||
}
|
||
|
||
fn on_warning(&mut self, message: impl Into<String>) {
|
||
self.add_to_history(history_cell::new_warning_event(message.into()));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
|
||
let mut status = self.mcp_startup_status.take().unwrap_or_default();
|
||
if let McpStartupStatus::Failed { error } = &ev.status {
|
||
self.on_warning(error);
|
||
}
|
||
status.insert(ev.server, ev.status);
|
||
self.mcp_startup_status = Some(status);
|
||
self.update_task_running_state();
|
||
if let Some(current) = &self.mcp_startup_status {
|
||
let total = current.len();
|
||
let mut starting: Vec<_> = current
|
||
.iter()
|
||
.filter_map(|(name, state)| {
|
||
if matches!(state, McpStartupStatus::Starting) {
|
||
Some(name)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
starting.sort();
|
||
if let Some(first) = starting.first() {
|
||
let completed = total.saturating_sub(starting.len());
|
||
let max_to_show = 3;
|
||
let mut to_show: Vec<String> = starting
|
||
.iter()
|
||
.take(max_to_show)
|
||
.map(ToString::to_string)
|
||
.collect();
|
||
if starting.len() > max_to_show {
|
||
to_show.push("…".to_string());
|
||
}
|
||
let header = if total > 1 {
|
||
format!(
|
||
"Starting MCP servers ({completed}/{total}): {}",
|
||
to_show.join(", ")
|
||
)
|
||
} else {
|
||
format!("Booting MCP server: {first}")
|
||
};
|
||
self.set_status_header(header);
|
||
}
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
|
||
let mut parts = Vec::new();
|
||
if !ev.failed.is_empty() {
|
||
let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect();
|
||
parts.push(format!("failed: {}", failed_servers.join(", ")));
|
||
}
|
||
if !ev.cancelled.is_empty() {
|
||
self.on_warning(format!(
|
||
"MCP startup interrupted. The following servers were not initialized: {}",
|
||
ev.cancelled.join(", ")
|
||
));
|
||
}
|
||
if !parts.is_empty() {
|
||
self.on_warning(format!("MCP startup incomplete ({})", parts.join("; ")));
|
||
}
|
||
|
||
self.mcp_startup_status = None;
|
||
self.update_task_running_state();
|
||
self.maybe_send_next_queued_input();
|
||
self.request_redraw();
|
||
}
|
||
|
||
/// Handle a turn aborted due to user interrupt (Esc).
|
||
/// When there are queued user messages, restore them into the composer
|
||
/// separated by newlines rather than auto‑submitting the next one.
|
||
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||
// Finalize, log a gentle prompt, and clear running state.
|
||
self.finalize_turn();
|
||
|
||
if reason != TurnAbortReason::ReviewEnded {
|
||
self.add_to_history(history_cell::new_error_event(
|
||
"Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(),
|
||
));
|
||
}
|
||
|
||
if let Some(combined) = self.drain_queued_messages_for_restore() {
|
||
let combined_local_image_paths = combined
|
||
.local_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect();
|
||
self.bottom_pane.set_composer_text(
|
||
combined.text,
|
||
combined.text_elements,
|
||
combined_local_image_paths,
|
||
);
|
||
self.refresh_queued_user_messages();
|
||
}
|
||
|
||
self.request_redraw();
|
||
}
|
||
|
||
/// Merge queued drafts (plus the current composer state) into a single message for restore.
|
||
///
|
||
/// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we
|
||
/// must renumber placeholders in a stable order so the merged attachment list stays aligned
|
||
/// with the labels embedded in text. This helper drains the queue, remaps placeholders, and
|
||
/// fixes text element byte ranges as content is appended. Returns `None` when there is nothing
|
||
/// to restore.
|
||
fn drain_queued_messages_for_restore(&mut self) -> Option<UserMessage> {
|
||
if self.queued_user_messages.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let existing_message = UserMessage {
|
||
text: self.bottom_pane.composer_text(),
|
||
text_elements: self.bottom_pane.composer_text_elements(),
|
||
local_images: self.bottom_pane.composer_local_images(),
|
||
mention_paths: HashMap::new(),
|
||
};
|
||
|
||
let mut to_merge: Vec<UserMessage> = self.queued_user_messages.drain(..).collect();
|
||
if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() {
|
||
to_merge.push(existing_message);
|
||
}
|
||
|
||
let mut combined = UserMessage {
|
||
text: String::new(),
|
||
text_elements: Vec::new(),
|
||
local_images: Vec::new(),
|
||
mention_paths: HashMap::new(),
|
||
};
|
||
let mut combined_offset = 0usize;
|
||
let mut next_image_label = 1usize;
|
||
|
||
for (idx, message) in to_merge.into_iter().enumerate() {
|
||
if idx > 0 {
|
||
combined.text.push('\n');
|
||
combined_offset += 1;
|
||
}
|
||
let message = remap_placeholders_for_message(message, &mut next_image_label);
|
||
let base = combined_offset;
|
||
combined.text.push_str(&message.text);
|
||
combined_offset += message.text.len();
|
||
combined
|
||
.text_elements
|
||
.extend(message.text_elements.into_iter().map(|mut elem| {
|
||
elem.byte_range.start += base;
|
||
elem.byte_range.end += base;
|
||
elem
|
||
}));
|
||
combined.local_images.extend(message.local_images);
|
||
combined.mention_paths.extend(message.mention_paths);
|
||
}
|
||
|
||
Some(combined)
|
||
}
|
||
|
||
fn on_plan_update(&mut self, update: UpdatePlanArgs) {
|
||
self.saw_plan_update_this_turn = true;
|
||
self.add_to_history(history_cell::new_plan_update(update));
|
||
}
|
||
|
||
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||
let id2 = id.clone();
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(
|
||
|q| q.push_exec_approval(id, ev),
|
||
|s| s.handle_exec_approval_now(id2, ev2),
|
||
);
|
||
}
|
||
|
||
fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) {
|
||
let id2 = id.clone();
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(
|
||
|q| q.push_apply_patch_approval(id, ev),
|
||
|s| s.handle_apply_patch_approval_now(id2, ev2),
|
||
);
|
||
}
|
||
|
||
fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) {
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(
|
||
|q| q.push_elicitation(ev),
|
||
|s| s.handle_elicitation_request_now(ev2),
|
||
);
|
||
}
|
||
|
||
fn on_request_user_input(&mut self, ev: RequestUserInputEvent) {
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(
|
||
|q| q.push_user_input(ev),
|
||
|s| s.handle_request_user_input_now(ev2),
|
||
);
|
||
}
|
||
|
||
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
if is_unified_exec_source(ev.source) {
|
||
self.track_unified_exec_process_begin(&ev);
|
||
if !is_standard_tool_call(&ev.parsed_cmd) {
|
||
return;
|
||
}
|
||
}
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
|
||
}
|
||
|
||
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
|
||
self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk);
|
||
|
||
let Some(cell) = self
|
||
.active_cell
|
||
.as_mut()
|
||
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
|
||
else {
|
||
return;
|
||
};
|
||
|
||
if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) {
|
||
self.bump_active_cell_revision();
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
|
||
fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) {
|
||
if !self.bottom_pane.is_task_running() {
|
||
return;
|
||
}
|
||
self.flush_answer_stream_with_separator();
|
||
let command_display = self
|
||
.unified_exec_processes
|
||
.iter()
|
||
.find(|process| process.key == ev.process_id)
|
||
.map(|process| process.command_display.clone());
|
||
if ev.stdin.is_empty() {
|
||
// Empty stdin means we are polling for background output.
|
||
// Surface this in the status header (single "waiting" surface) instead of the transcript.
|
||
self.bottom_pane.ensure_status_indicator();
|
||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||
let header = if let Some(command) = &command_display {
|
||
format!("Waiting for background terminal · {command}")
|
||
} else {
|
||
"Waiting for background terminal".to_string()
|
||
};
|
||
self.set_status_header(header);
|
||
match &mut self.unified_exec_wait_streak {
|
||
Some(wait) if wait.process_id == ev.process_id => {
|
||
wait.update_command_display(command_display);
|
||
}
|
||
Some(_) => {
|
||
self.flush_unified_exec_wait_streak();
|
||
self.unified_exec_wait_streak =
|
||
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
|
||
}
|
||
None => {
|
||
self.unified_exec_wait_streak =
|
||
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
|
||
}
|
||
}
|
||
self.request_redraw();
|
||
} else {
|
||
if self
|
||
.unified_exec_wait_streak
|
||
.as_ref()
|
||
.is_some_and(|wait| wait.process_id == ev.process_id)
|
||
{
|
||
self.flush_unified_exec_wait_streak();
|
||
}
|
||
self.add_to_history(history_cell::new_unified_exec_interaction(
|
||
command_display,
|
||
ev.stdin,
|
||
));
|
||
}
|
||
}
|
||
|
||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||
self.add_to_history(history_cell::new_patch_event(
|
||
event.changes,
|
||
&self.config.cwd,
|
||
));
|
||
}
|
||
|
||
fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
self.add_to_history(history_cell::new_view_image_tool_call(
|
||
event.path,
|
||
&self.config.cwd,
|
||
));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
|
||
let ev2 = event.clone();
|
||
self.defer_or_handle(
|
||
|q| q.push_patch_end(event),
|
||
|s| s.handle_patch_apply_end_now(ev2),
|
||
);
|
||
}
|
||
|
||
fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) {
|
||
if is_unified_exec_source(ev.source) {
|
||
if let Some(process_id) = ev.process_id.as_deref()
|
||
&& self
|
||
.unified_exec_wait_streak
|
||
.as_ref()
|
||
.is_some_and(|wait| wait.process_id == process_id)
|
||
{
|
||
self.flush_unified_exec_wait_streak();
|
||
}
|
||
self.track_unified_exec_process_end(&ev);
|
||
if !self.bottom_pane.is_task_running() {
|
||
return;
|
||
}
|
||
}
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2));
|
||
}
|
||
|
||
fn track_unified_exec_process_begin(&mut self, ev: &ExecCommandBeginEvent) {
|
||
if ev.source != ExecCommandSource::UnifiedExecStartup {
|
||
return;
|
||
}
|
||
let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string());
|
||
let command_display = strip_bash_lc_and_escape(&ev.command);
|
||
if let Some(existing) = self
|
||
.unified_exec_processes
|
||
.iter_mut()
|
||
.find(|process| process.key == key)
|
||
{
|
||
existing.call_id = ev.call_id.clone();
|
||
existing.command_display = command_display;
|
||
existing.recent_chunks.clear();
|
||
} else {
|
||
self.unified_exec_processes.push(UnifiedExecProcessSummary {
|
||
key,
|
||
call_id: ev.call_id.clone(),
|
||
command_display,
|
||
recent_chunks: Vec::new(),
|
||
});
|
||
}
|
||
self.sync_unified_exec_footer();
|
||
}
|
||
|
||
fn track_unified_exec_process_end(&mut self, ev: &ExecCommandEndEvent) {
|
||
let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string());
|
||
let before = self.unified_exec_processes.len();
|
||
self.unified_exec_processes
|
||
.retain(|process| process.key != key);
|
||
if self.unified_exec_processes.len() != before {
|
||
self.sync_unified_exec_footer();
|
||
}
|
||
}
|
||
|
||
fn sync_unified_exec_footer(&mut self) {
|
||
let processes = self
|
||
.unified_exec_processes
|
||
.iter()
|
||
.map(|process| process.command_display.clone())
|
||
.collect();
|
||
self.bottom_pane.set_unified_exec_processes(processes);
|
||
}
|
||
|
||
/// Record recent stdout/stderr lines for the unified exec footer.
|
||
fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) {
|
||
let Some(process) = self
|
||
.unified_exec_processes
|
||
.iter_mut()
|
||
.find(|process| process.call_id == call_id)
|
||
else {
|
||
return;
|
||
};
|
||
|
||
let text = String::from_utf8_lossy(chunk);
|
||
for line in text
|
||
.lines()
|
||
.map(str::trim_end)
|
||
.filter(|line| !line.is_empty())
|
||
{
|
||
process.recent_chunks.push(line.to_string());
|
||
}
|
||
|
||
const MAX_RECENT_CHUNKS: usize = 3;
|
||
if process.recent_chunks.len() > MAX_RECENT_CHUNKS {
|
||
let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS;
|
||
process.recent_chunks.drain(0..drop_count);
|
||
}
|
||
}
|
||
|
||
fn clear_unified_exec_processes(&mut self) {
|
||
if self.unified_exec_processes.is_empty() {
|
||
return;
|
||
}
|
||
self.unified_exec_processes.clear();
|
||
self.sync_unified_exec_footer();
|
||
}
|
||
|
||
fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2));
|
||
}
|
||
|
||
fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) {
|
||
let ev2 = ev.clone();
|
||
self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2));
|
||
}
|
||
|
||
fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
self.flush_active_cell();
|
||
self.active_cell = Some(Box::new(history_cell::new_active_web_search_call(
|
||
ev.call_id,
|
||
String::new(),
|
||
self.config.animations,
|
||
)));
|
||
self.bump_active_cell_revision();
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_web_search_end(&mut self, ev: WebSearchEndEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
let WebSearchEndEvent {
|
||
call_id,
|
||
query,
|
||
action,
|
||
} = ev;
|
||
let mut handled = false;
|
||
if let Some(cell) = self
|
||
.active_cell
|
||
.as_mut()
|
||
.and_then(|cell| cell.as_any_mut().downcast_mut::<WebSearchCell>())
|
||
&& cell.call_id() == call_id
|
||
{
|
||
cell.update(action.clone(), query.clone());
|
||
cell.complete();
|
||
self.bump_active_cell_revision();
|
||
self.flush_active_cell();
|
||
handled = true;
|
||
}
|
||
|
||
if !handled {
|
||
self.add_to_history(history_cell::new_web_search_call(call_id, query, action));
|
||
}
|
||
self.had_work_activity = true;
|
||
}
|
||
|
||
fn on_collab_event(&mut self, cell: PlainHistoryCell) {
|
||
self.flush_answer_stream_with_separator();
|
||
self.add_to_history(cell);
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_get_history_entry_response(
|
||
&mut self,
|
||
event: codex_core::protocol::GetHistoryEntryResponseEvent,
|
||
) {
|
||
let codex_core::protocol::GetHistoryEntryResponseEvent {
|
||
offset,
|
||
log_id,
|
||
entry,
|
||
} = event;
|
||
self.bottom_pane
|
||
.on_history_entry_response(log_id, offset, entry.map(|e| e.text));
|
||
}
|
||
|
||
fn on_shutdown_complete(&mut self) {
|
||
self.request_immediate_exit();
|
||
}
|
||
|
||
fn on_turn_diff(&mut self, unified_diff: String) {
|
||
debug!("TurnDiffEvent: {unified_diff}");
|
||
}
|
||
|
||
fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) {
|
||
let DeprecationNoticeEvent { summary, details } = event;
|
||
self.add_to_history(history_cell::new_deprecation_notice(summary, details));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_background_event(&mut self, message: String) {
|
||
debug!("BackgroundEvent: {message}");
|
||
self.bottom_pane.ensure_status_indicator();
|
||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||
self.set_status_header(message);
|
||
}
|
||
|
||
fn on_undo_started(&mut self, event: UndoStartedEvent) {
|
||
self.bottom_pane.ensure_status_indicator();
|
||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||
let message = event
|
||
.message
|
||
.unwrap_or_else(|| "Undo in progress...".to_string());
|
||
self.set_status_header(message);
|
||
}
|
||
|
||
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
|
||
let UndoCompletedEvent { success, message } = event;
|
||
self.bottom_pane.hide_status_indicator();
|
||
let message = message.unwrap_or_else(|| {
|
||
if success {
|
||
"Undo completed successfully.".to_string()
|
||
} else {
|
||
"Undo failed.".to_string()
|
||
}
|
||
});
|
||
if success {
|
||
self.add_info_message(message, None);
|
||
} else {
|
||
self.add_error_message(message);
|
||
}
|
||
}
|
||
|
||
fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
|
||
if self.retry_status_header.is_none() {
|
||
self.retry_status_header = Some(self.current_status_header.clone());
|
||
}
|
||
self.set_status(message, additional_details);
|
||
}
|
||
|
||
/// Periodic tick to commit at most one queued line to history with a small delay,
|
||
/// animating the output.
|
||
pub(crate) fn on_commit_tick(&mut self) {
|
||
if let Some(controller) = self.stream_controller.as_mut() {
|
||
let (cell, is_idle) = controller.on_commit_tick();
|
||
if let Some(cell) = cell {
|
||
self.bottom_pane.hide_status_indicator();
|
||
self.add_boxed_history(cell);
|
||
}
|
||
if is_idle {
|
||
self.app_event_tx.send(AppEvent::StopCommitAnimation);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn flush_interrupt_queue(&mut self) {
|
||
let mut mgr = std::mem::take(&mut self.interrupts);
|
||
mgr.flush_all(self);
|
||
self.interrupts = mgr;
|
||
}
|
||
|
||
#[inline]
|
||
fn defer_or_handle(
|
||
&mut self,
|
||
push: impl FnOnce(&mut InterruptManager),
|
||
handle: impl FnOnce(&mut Self),
|
||
) {
|
||
// Preserve deterministic FIFO across queued interrupts: once anything
|
||
// is queued due to an active write cycle, continue queueing until the
|
||
// queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin).
|
||
if self.stream_controller.is_some() || !self.interrupts.is_empty() {
|
||
push(&mut self.interrupts);
|
||
} else {
|
||
handle(self);
|
||
}
|
||
}
|
||
|
||
fn handle_stream_finished(&mut self) {
|
||
if self.task_complete_pending {
|
||
self.bottom_pane.hide_status_indicator();
|
||
self.task_complete_pending = false;
|
||
}
|
||
// A completed stream indicates non-exec content was just inserted.
|
||
self.flush_interrupt_queue();
|
||
}
|
||
|
||
#[inline]
|
||
fn handle_streaming_delta(&mut self, delta: String) {
|
||
// Before streaming agent content, flush any active exec cell group.
|
||
self.flush_unified_exec_wait_streak();
|
||
self.flush_active_cell();
|
||
|
||
if self.stream_controller.is_none() {
|
||
// If the previous turn inserted non-stream history (exec output, patch status, MCP
|
||
// calls), render a separator before starting the next streamed assistant message.
|
||
if self.needs_final_message_separator && self.had_work_activity {
|
||
let elapsed_seconds = self
|
||
.bottom_pane
|
||
.status_widget()
|
||
.map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds)
|
||
.map(|current| self.worked_elapsed_from(current));
|
||
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
|
||
self.needs_final_message_separator = false;
|
||
self.had_work_activity = false;
|
||
} else if self.needs_final_message_separator {
|
||
// Reset the flag even if we don't show separator (no work was done)
|
||
self.needs_final_message_separator = false;
|
||
}
|
||
self.stream_controller = Some(StreamController::new(
|
||
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
|
||
));
|
||
}
|
||
if let Some(controller) = self.stream_controller.as_mut()
|
||
&& controller.push(&delta)
|
||
{
|
||
self.app_event_tx.send(AppEvent::StartCommitAnimation);
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 {
|
||
let baseline = match self.last_separator_elapsed_secs {
|
||
Some(last) if current_elapsed < last => 0,
|
||
Some(last) => last,
|
||
None => 0,
|
||
};
|
||
let elapsed = current_elapsed.saturating_sub(baseline);
|
||
self.last_separator_elapsed_secs = Some(current_elapsed);
|
||
elapsed
|
||
}
|
||
|
||
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
|
||
let running = self.running_commands.remove(&ev.call_id);
|
||
if self.suppressed_exec_calls.remove(&ev.call_id) {
|
||
return;
|
||
}
|
||
let (command, parsed, source) = match running {
|
||
Some(rc) => (rc.command, rc.parsed_cmd, rc.source),
|
||
None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source),
|
||
};
|
||
let is_unified_exec_interaction =
|
||
matches!(source, ExecCommandSource::UnifiedExecInteraction);
|
||
|
||
let needs_new = self
|
||
.active_cell
|
||
.as_ref()
|
||
.map(|cell| cell.as_any().downcast_ref::<ExecCell>().is_none())
|
||
.unwrap_or(true);
|
||
if needs_new {
|
||
self.flush_active_cell();
|
||
self.active_cell = Some(Box::new(new_active_exec_command(
|
||
ev.call_id.clone(),
|
||
command,
|
||
parsed,
|
||
source,
|
||
ev.interaction_input.clone(),
|
||
self.config.animations,
|
||
)));
|
||
}
|
||
|
||
if let Some(cell) = self
|
||
.active_cell
|
||
.as_mut()
|
||
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
|
||
{
|
||
let output = if is_unified_exec_interaction {
|
||
CommandOutput {
|
||
exit_code: ev.exit_code,
|
||
formatted_output: String::new(),
|
||
aggregated_output: String::new(),
|
||
}
|
||
} else {
|
||
CommandOutput {
|
||
exit_code: ev.exit_code,
|
||
formatted_output: ev.formatted_output.clone(),
|
||
aggregated_output: ev.aggregated_output.clone(),
|
||
}
|
||
};
|
||
cell.complete_call(&ev.call_id, output, ev.duration);
|
||
if cell.should_flush() {
|
||
self.flush_active_cell();
|
||
} else {
|
||
self.bump_active_cell_revision();
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
// Mark that actual work was done (command executed)
|
||
self.had_work_activity = true;
|
||
}
|
||
|
||
pub(crate) fn handle_patch_apply_end_now(
|
||
&mut self,
|
||
event: codex_core::protocol::PatchApplyEndEvent,
|
||
) {
|
||
// If the patch was successful, just let the "Edited" block stand.
|
||
// Otherwise, add a failure block.
|
||
if !event.success {
|
||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||
}
|
||
// Mark that actual work was done (patch applied)
|
||
self.had_work_activity = true;
|
||
}
|
||
|
||
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
let command = shlex::try_join(ev.command.iter().map(String::as_str))
|
||
.unwrap_or_else(|_| ev.command.join(" "));
|
||
self.notify(Notification::ExecApprovalRequested { command });
|
||
|
||
let request = ApprovalRequest::Exec {
|
||
id,
|
||
command: ev.command,
|
||
reason: ev.reason,
|
||
proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment,
|
||
};
|
||
self.bottom_pane
|
||
.push_approval_request(request, &self.config.features);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn handle_apply_patch_approval_now(
|
||
&mut self,
|
||
id: String,
|
||
ev: ApplyPatchApprovalRequestEvent,
|
||
) {
|
||
self.flush_answer_stream_with_separator();
|
||
|
||
let request = ApprovalRequest::ApplyPatch {
|
||
id,
|
||
reason: ev.reason,
|
||
changes: ev.changes.clone(),
|
||
cwd: self.config.cwd.clone(),
|
||
};
|
||
self.bottom_pane
|
||
.push_approval_request(request, &self.config.features);
|
||
self.request_redraw();
|
||
self.notify(Notification::EditApprovalRequested {
|
||
cwd: self.config.cwd.clone(),
|
||
changes: ev.changes.keys().cloned().collect(),
|
||
});
|
||
}
|
||
|
||
pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
|
||
self.notify(Notification::ElicitationRequested {
|
||
server_name: ev.server_name.clone(),
|
||
});
|
||
|
||
let request = ApprovalRequest::McpElicitation {
|
||
server_name: ev.server_name,
|
||
request_id: ev.id,
|
||
message: ev.message,
|
||
};
|
||
self.bottom_pane
|
||
.push_approval_request(request, &self.config.features);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
self.bottom_pane.push_user_input_request(ev);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
|
||
// Ensure the status indicator is visible while the command runs.
|
||
self.running_commands.insert(
|
||
ev.call_id.clone(),
|
||
RunningCommand {
|
||
command: ev.command.clone(),
|
||
parsed_cmd: ev.parsed_cmd.clone(),
|
||
source: ev.source,
|
||
},
|
||
);
|
||
let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction)
|
||
&& ev
|
||
.interaction_input
|
||
.as_deref()
|
||
.map(str::is_empty)
|
||
.unwrap_or(true);
|
||
let command_display = ev.command.join(" ");
|
||
let should_suppress_unified_wait = is_wait_interaction
|
||
&& self
|
||
.last_unified_wait
|
||
.as_ref()
|
||
.is_some_and(|wait| wait.is_duplicate(&command_display));
|
||
if is_wait_interaction {
|
||
self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display));
|
||
} else {
|
||
self.last_unified_wait = None;
|
||
}
|
||
if should_suppress_unified_wait {
|
||
self.suppressed_exec_calls.insert(ev.call_id);
|
||
return;
|
||
}
|
||
let interaction_input = ev.interaction_input.clone();
|
||
if let Some(cell) = self
|
||
.active_cell
|
||
.as_mut()
|
||
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
|
||
&& let Some(new_exec) = cell.with_added_call(
|
||
ev.call_id.clone(),
|
||
ev.command.clone(),
|
||
ev.parsed_cmd.clone(),
|
||
ev.source,
|
||
interaction_input.clone(),
|
||
)
|
||
{
|
||
*cell = new_exec;
|
||
self.bump_active_cell_revision();
|
||
} else {
|
||
self.flush_active_cell();
|
||
|
||
self.active_cell = Some(Box::new(new_active_exec_command(
|
||
ev.call_id.clone(),
|
||
ev.command.clone(),
|
||
ev.parsed_cmd,
|
||
ev.source,
|
||
interaction_input,
|
||
self.config.animations,
|
||
)));
|
||
self.bump_active_cell_revision();
|
||
}
|
||
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
self.flush_active_cell();
|
||
self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
|
||
ev.call_id,
|
||
ev.invocation,
|
||
self.config.animations,
|
||
)));
|
||
self.bump_active_cell_revision();
|
||
self.request_redraw();
|
||
}
|
||
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
|
||
self.flush_answer_stream_with_separator();
|
||
|
||
let McpToolCallEndEvent {
|
||
call_id,
|
||
invocation,
|
||
duration,
|
||
result,
|
||
} = ev;
|
||
|
||
let extra_cell = match self
|
||
.active_cell
|
||
.as_mut()
|
||
.and_then(|cell| cell.as_any_mut().downcast_mut::<McpToolCallCell>())
|
||
{
|
||
Some(cell) if cell.call_id() == call_id => cell.complete(duration, result),
|
||
_ => {
|
||
self.flush_active_cell();
|
||
let mut cell = history_cell::new_active_mcp_tool_call(
|
||
call_id,
|
||
invocation,
|
||
self.config.animations,
|
||
);
|
||
let extra_cell = cell.complete(duration, result);
|
||
self.active_cell = Some(Box::new(cell));
|
||
extra_cell
|
||
}
|
||
};
|
||
|
||
self.flush_active_cell();
|
||
if let Some(extra) = extra_cell {
|
||
self.add_boxed_history(extra);
|
||
}
|
||
// Mark that actual work was done (MCP tool call)
|
||
self.had_work_activity = true;
|
||
}
|
||
|
||
pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc<ThreadManager>) -> Self {
|
||
let ChatWidgetInit {
|
||
config,
|
||
frame_requester,
|
||
app_event_tx,
|
||
initial_user_message,
|
||
enhanced_keys_supported,
|
||
auth_manager,
|
||
models_manager,
|
||
feedback,
|
||
is_first_run,
|
||
feedback_audience,
|
||
model,
|
||
otel_manager,
|
||
} = common;
|
||
let model = model.filter(|m| !m.trim().is_empty());
|
||
let mut config = config;
|
||
config.model = model.clone();
|
||
let mut rng = rand::rng();
|
||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
|
||
|
||
let model_override = model.as_deref();
|
||
let model_for_header = model
|
||
.clone()
|
||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||
let active_collaboration_mask =
|
||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||
let header_model = active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.model.clone())
|
||
.unwrap_or_else(|| model_for_header.clone());
|
||
let fallback_custom = Settings {
|
||
model: header_model.clone(),
|
||
reasoning_effort: None,
|
||
developer_instructions: None,
|
||
};
|
||
// Collaboration modes start in Custom mode (not activated).
|
||
let current_collaboration_mode = CollaborationMode {
|
||
mode: ModeKind::Custom,
|
||
settings: fallback_custom,
|
||
};
|
||
|
||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||
|
||
let mut widget = Self {
|
||
app_event_tx: app_event_tx.clone(),
|
||
frame_requester: frame_requester.clone(),
|
||
codex_op_tx,
|
||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||
frame_requester,
|
||
app_event_tx,
|
||
has_input_focus: true,
|
||
enhanced_keys_supported,
|
||
placeholder_text: placeholder,
|
||
disable_paste_burst: config.disable_paste_burst,
|
||
animations_enabled: config.animations,
|
||
skills: None,
|
||
}),
|
||
active_cell,
|
||
active_cell_revision: 0,
|
||
config,
|
||
skills_all: Vec::new(),
|
||
skills_initial_state: None,
|
||
current_collaboration_mode,
|
||
active_collaboration_mask,
|
||
auth_manager,
|
||
models_manager,
|
||
otel_manager,
|
||
session_header: SessionHeader::new(header_model),
|
||
initial_user_message,
|
||
token_info: None,
|
||
rate_limit_snapshot: None,
|
||
plan_type: None,
|
||
rate_limit_warnings: RateLimitWarningState::default(),
|
||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||
rate_limit_poller: None,
|
||
stream_controller: None,
|
||
running_commands: HashMap::new(),
|
||
suppressed_exec_calls: HashSet::new(),
|
||
last_unified_wait: None,
|
||
unified_exec_wait_streak: None,
|
||
task_complete_pending: false,
|
||
unified_exec_processes: Vec::new(),
|
||
agent_turn_running: false,
|
||
mcp_startup_status: None,
|
||
connectors_cache: ConnectorsCacheState::default(),
|
||
interrupts: InterruptManager::new(),
|
||
reasoning_buffer: String::new(),
|
||
full_reasoning_buffer: String::new(),
|
||
current_status_header: String::from("Working"),
|
||
retry_status_header: None,
|
||
thread_id: None,
|
||
thread_name: None,
|
||
forked_from: None,
|
||
queued_user_messages: VecDeque::new(),
|
||
show_welcome_banner: is_first_run,
|
||
suppress_session_configured_redraw: false,
|
||
pending_notification: None,
|
||
quit_shortcut_expires_at: None,
|
||
quit_shortcut_key: None,
|
||
is_review_mode: false,
|
||
pre_review_token_info: None,
|
||
needs_final_message_separator: false,
|
||
had_work_activity: false,
|
||
saw_plan_update_this_turn: false,
|
||
last_separator_elapsed_secs: None,
|
||
last_rendered_width: std::cell::Cell::new(None),
|
||
feedback,
|
||
feedback_audience,
|
||
current_rollout_path: None,
|
||
external_editor_state: ExternalEditorState::Closed,
|
||
};
|
||
|
||
widget.prefetch_rate_limits();
|
||
widget
|
||
.bottom_pane
|
||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||
widget.config.features.enabled(Feature::CollaborationModes),
|
||
);
|
||
widget.sync_personality_command_enabled();
|
||
#[cfg(target_os = "windows")]
|
||
widget.bottom_pane.set_windows_degraded_sandbox_active(
|
||
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
&& matches!(
|
||
WindowsSandboxLevel::from_config(&widget.config),
|
||
WindowsSandboxLevel::RestrictedToken
|
||
),
|
||
);
|
||
widget.update_collaboration_mode_indicator();
|
||
|
||
widget
|
||
.bottom_pane
|
||
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
|
||
|
||
widget
|
||
}
|
||
|
||
pub(crate) fn new_with_op_sender(
|
||
common: ChatWidgetInit,
|
||
codex_op_tx: UnboundedSender<Op>,
|
||
) -> Self {
|
||
let ChatWidgetInit {
|
||
config,
|
||
frame_requester,
|
||
app_event_tx,
|
||
initial_user_message,
|
||
enhanced_keys_supported,
|
||
auth_manager,
|
||
models_manager,
|
||
feedback,
|
||
is_first_run,
|
||
feedback_audience,
|
||
model,
|
||
otel_manager,
|
||
} = common;
|
||
let model = model.filter(|m| !m.trim().is_empty());
|
||
let mut config = config;
|
||
config.model = model.clone();
|
||
let mut rng = rand::rng();
|
||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||
|
||
let model_override = model.as_deref();
|
||
let model_for_header = model
|
||
.clone()
|
||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||
let active_collaboration_mask =
|
||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||
let header_model = active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.model.clone())
|
||
.unwrap_or_else(|| model_for_header.clone());
|
||
let fallback_custom = Settings {
|
||
model: header_model.clone(),
|
||
reasoning_effort: None,
|
||
developer_instructions: None,
|
||
};
|
||
// Collaboration modes start in Custom mode (not activated).
|
||
let current_collaboration_mode = CollaborationMode {
|
||
mode: ModeKind::Custom,
|
||
settings: fallback_custom,
|
||
};
|
||
|
||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||
|
||
let mut widget = Self {
|
||
app_event_tx: app_event_tx.clone(),
|
||
frame_requester: frame_requester.clone(),
|
||
codex_op_tx,
|
||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||
frame_requester,
|
||
app_event_tx,
|
||
has_input_focus: true,
|
||
enhanced_keys_supported,
|
||
placeholder_text: placeholder,
|
||
disable_paste_burst: config.disable_paste_burst,
|
||
animations_enabled: config.animations,
|
||
skills: None,
|
||
}),
|
||
active_cell,
|
||
active_cell_revision: 0,
|
||
config,
|
||
skills_all: Vec::new(),
|
||
skills_initial_state: None,
|
||
current_collaboration_mode,
|
||
active_collaboration_mask,
|
||
auth_manager,
|
||
models_manager,
|
||
otel_manager,
|
||
session_header: SessionHeader::new(header_model),
|
||
initial_user_message,
|
||
token_info: None,
|
||
rate_limit_snapshot: None,
|
||
plan_type: None,
|
||
rate_limit_warnings: RateLimitWarningState::default(),
|
||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||
rate_limit_poller: None,
|
||
stream_controller: None,
|
||
running_commands: HashMap::new(),
|
||
suppressed_exec_calls: HashSet::new(),
|
||
last_unified_wait: None,
|
||
unified_exec_wait_streak: None,
|
||
task_complete_pending: false,
|
||
unified_exec_processes: Vec::new(),
|
||
agent_turn_running: false,
|
||
mcp_startup_status: None,
|
||
connectors_cache: ConnectorsCacheState::default(),
|
||
interrupts: InterruptManager::new(),
|
||
reasoning_buffer: String::new(),
|
||
full_reasoning_buffer: String::new(),
|
||
current_status_header: String::from("Working"),
|
||
retry_status_header: None,
|
||
thread_id: None,
|
||
thread_name: None,
|
||
forked_from: None,
|
||
saw_plan_update_this_turn: false,
|
||
queued_user_messages: VecDeque::new(),
|
||
show_welcome_banner: is_first_run,
|
||
suppress_session_configured_redraw: false,
|
||
pending_notification: None,
|
||
quit_shortcut_expires_at: None,
|
||
quit_shortcut_key: None,
|
||
is_review_mode: false,
|
||
pre_review_token_info: None,
|
||
needs_final_message_separator: false,
|
||
had_work_activity: false,
|
||
last_separator_elapsed_secs: None,
|
||
last_rendered_width: std::cell::Cell::new(None),
|
||
feedback,
|
||
feedback_audience,
|
||
current_rollout_path: None,
|
||
external_editor_state: ExternalEditorState::Closed,
|
||
};
|
||
|
||
widget.prefetch_rate_limits();
|
||
widget
|
||
.bottom_pane
|
||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||
widget.config.features.enabled(Feature::CollaborationModes),
|
||
);
|
||
widget.sync_personality_command_enabled();
|
||
|
||
widget
|
||
}
|
||
|
||
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
|
||
pub(crate) fn new_from_existing(
|
||
common: ChatWidgetInit,
|
||
conversation: std::sync::Arc<codex_core::CodexThread>,
|
||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||
) -> Self {
|
||
let ChatWidgetInit {
|
||
config,
|
||
frame_requester,
|
||
app_event_tx,
|
||
initial_user_message,
|
||
enhanced_keys_supported,
|
||
auth_manager,
|
||
models_manager,
|
||
feedback,
|
||
feedback_audience,
|
||
model,
|
||
otel_manager,
|
||
..
|
||
} = common;
|
||
let model = model.filter(|m| !m.trim().is_empty());
|
||
let mut rng = rand::rng();
|
||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||
|
||
let model_override = model.as_deref();
|
||
let header_model = model
|
||
.clone()
|
||
.unwrap_or_else(|| session_configured.model.clone());
|
||
let active_collaboration_mask =
|
||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||
let header_model = active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.model.clone())
|
||
.unwrap_or(header_model);
|
||
|
||
let codex_op_tx =
|
||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||
|
||
let fallback_custom = Settings {
|
||
model: header_model.clone(),
|
||
reasoning_effort: None,
|
||
developer_instructions: None,
|
||
};
|
||
// Collaboration modes start in Custom mode (not activated).
|
||
let current_collaboration_mode = CollaborationMode {
|
||
mode: ModeKind::Custom,
|
||
settings: fallback_custom,
|
||
};
|
||
|
||
let mut widget = Self {
|
||
app_event_tx: app_event_tx.clone(),
|
||
frame_requester: frame_requester.clone(),
|
||
codex_op_tx,
|
||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||
frame_requester,
|
||
app_event_tx,
|
||
has_input_focus: true,
|
||
enhanced_keys_supported,
|
||
placeholder_text: placeholder,
|
||
disable_paste_burst: config.disable_paste_burst,
|
||
animations_enabled: config.animations,
|
||
skills: None,
|
||
}),
|
||
active_cell: None,
|
||
active_cell_revision: 0,
|
||
config,
|
||
skills_all: Vec::new(),
|
||
skills_initial_state: None,
|
||
current_collaboration_mode,
|
||
active_collaboration_mask,
|
||
auth_manager,
|
||
models_manager,
|
||
otel_manager,
|
||
session_header: SessionHeader::new(header_model),
|
||
initial_user_message,
|
||
token_info: None,
|
||
rate_limit_snapshot: None,
|
||
plan_type: None,
|
||
rate_limit_warnings: RateLimitWarningState::default(),
|
||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||
rate_limit_poller: None,
|
||
stream_controller: None,
|
||
running_commands: HashMap::new(),
|
||
suppressed_exec_calls: HashSet::new(),
|
||
last_unified_wait: None,
|
||
unified_exec_wait_streak: None,
|
||
task_complete_pending: false,
|
||
unified_exec_processes: Vec::new(),
|
||
agent_turn_running: false,
|
||
mcp_startup_status: None,
|
||
connectors_cache: ConnectorsCacheState::default(),
|
||
interrupts: InterruptManager::new(),
|
||
reasoning_buffer: String::new(),
|
||
full_reasoning_buffer: String::new(),
|
||
current_status_header: String::from("Working"),
|
||
retry_status_header: None,
|
||
thread_id: None,
|
||
thread_name: None,
|
||
forked_from: None,
|
||
queued_user_messages: VecDeque::new(),
|
||
show_welcome_banner: false,
|
||
suppress_session_configured_redraw: true,
|
||
pending_notification: None,
|
||
quit_shortcut_expires_at: None,
|
||
quit_shortcut_key: None,
|
||
is_review_mode: false,
|
||
pre_review_token_info: None,
|
||
needs_final_message_separator: false,
|
||
had_work_activity: false,
|
||
saw_plan_update_this_turn: false,
|
||
last_separator_elapsed_secs: None,
|
||
last_rendered_width: std::cell::Cell::new(None),
|
||
feedback,
|
||
feedback_audience,
|
||
current_rollout_path: None,
|
||
external_editor_state: ExternalEditorState::Closed,
|
||
};
|
||
|
||
widget.prefetch_rate_limits();
|
||
widget
|
||
.bottom_pane
|
||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||
widget.config.features.enabled(Feature::CollaborationModes),
|
||
);
|
||
widget.sync_personality_command_enabled();
|
||
#[cfg(target_os = "windows")]
|
||
widget.bottom_pane.set_windows_degraded_sandbox_active(
|
||
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
&& matches!(
|
||
WindowsSandboxLevel::from_config(&widget.config),
|
||
WindowsSandboxLevel::RestrictedToken
|
||
),
|
||
);
|
||
widget.update_collaboration_mode_indicator();
|
||
|
||
widget
|
||
}
|
||
|
||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {
|
||
self.on_ctrl_c();
|
||
return;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
||
if self.on_ctrl_d() {
|
||
return;
|
||
}
|
||
self.bottom_pane.clear_quit_shortcut_hint();
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||
&& c.eq_ignore_ascii_case(&'v') =>
|
||
{
|
||
match paste_image_to_temp_png() {
|
||
Ok((path, info)) => {
|
||
tracing::debug!(
|
||
"pasted image size={}x{} format={}",
|
||
info.width,
|
||
info.height,
|
||
info.encoded_format.label()
|
||
);
|
||
self.attach_image(path);
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!("failed to paste image: {err}");
|
||
self.add_to_history(history_cell::new_error_event(format!(
|
||
"Failed to paste image: {err}",
|
||
)));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
other if other.kind == KeyEventKind::Press => {
|
||
self.bottom_pane.clear_quit_shortcut_hint();
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::BackTab,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if self.collaboration_modes_enabled()
|
||
&& !self.bottom_pane.is_task_running()
|
||
&& self.bottom_pane.no_modal_or_popup_active() =>
|
||
{
|
||
self.cycle_collaboration_mode();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Up,
|
||
modifiers: KeyModifiers::ALT,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} if !self.queued_user_messages.is_empty() => {
|
||
// Prefer the most recently queued item.
|
||
if let Some(user_message) = self.queued_user_messages.pop_back() {
|
||
let local_image_paths = user_message
|
||
.local_images
|
||
.iter()
|
||
.map(|img| img.path.clone())
|
||
.collect();
|
||
self.bottom_pane.set_composer_text(
|
||
user_message.text,
|
||
user_message.text_elements,
|
||
local_image_paths,
|
||
);
|
||
self.refresh_queued_user_messages();
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
_ => match self.bottom_pane.handle_key_event(key_event) {
|
||
InputResult::Submitted {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let user_message = UserMessage {
|
||
text,
|
||
local_images: self
|
||
.bottom_pane
|
||
.take_recent_submission_images_with_placeholders(),
|
||
text_elements,
|
||
mention_paths: self.bottom_pane.take_mention_paths(),
|
||
};
|
||
if self.is_session_configured() {
|
||
// Submitted is only emitted when steer is enabled (Enter sends immediately).
|
||
// Reset any reasoning header only when we are actually submitting a turn.
|
||
self.reasoning_buffer.clear();
|
||
self.full_reasoning_buffer.clear();
|
||
self.set_status_header(String::from("Working"));
|
||
self.submit_user_message(user_message);
|
||
} else {
|
||
self.queue_user_message(user_message);
|
||
}
|
||
}
|
||
InputResult::Queued {
|
||
text,
|
||
text_elements,
|
||
} => {
|
||
let user_message = UserMessage {
|
||
text,
|
||
local_images: self
|
||
.bottom_pane
|
||
.take_recent_submission_images_with_placeholders(),
|
||
text_elements,
|
||
mention_paths: self.bottom_pane.take_mention_paths(),
|
||
};
|
||
self.queue_user_message(user_message);
|
||
}
|
||
InputResult::Command(cmd) => {
|
||
self.dispatch_command(cmd);
|
||
}
|
||
InputResult::CommandWithArgs(cmd, args) => {
|
||
self.dispatch_command_with_args(cmd, args);
|
||
}
|
||
InputResult::None => {}
|
||
},
|
||
}
|
||
}
|
||
|
||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||
tracing::info!("attach_image path={path:?}");
|
||
self.bottom_pane.attach_image(path);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn composer_text_with_pending(&self) -> String {
|
||
self.bottom_pane.composer_text_with_pending()
|
||
}
|
||
|
||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||
self.bottom_pane.apply_external_edit(text);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn external_editor_state(&self) -> ExternalEditorState {
|
||
self.external_editor_state
|
||
}
|
||
|
||
pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) {
|
||
self.external_editor_state = state;
|
||
}
|
||
|
||
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||
self.bottom_pane.set_footer_hint_override(items);
|
||
}
|
||
|
||
pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) {
|
||
self.bottom_pane.show_selection_view(params);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn can_launch_external_editor(&self) -> bool {
|
||
self.bottom_pane.can_launch_external_editor()
|
||
}
|
||
|
||
fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||
let message = format!(
|
||
"'/{}' is disabled while a task is in progress.",
|
||
cmd.command()
|
||
);
|
||
self.add_to_history(history_cell::new_error_event(message));
|
||
self.request_redraw();
|
||
return;
|
||
}
|
||
match cmd {
|
||
SlashCommand::Feedback => {
|
||
if !self.config.feedback_enabled {
|
||
let params = crate::bottom_pane::feedback_disabled_params();
|
||
self.bottom_pane.show_selection_view(params);
|
||
self.request_redraw();
|
||
return;
|
||
}
|
||
// Step 1: pick a category (UI built in feedback_view)
|
||
let params =
|
||
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
||
self.bottom_pane.show_selection_view(params);
|
||
self.request_redraw();
|
||
}
|
||
SlashCommand::New => {
|
||
self.app_event_tx.send(AppEvent::NewSession);
|
||
}
|
||
SlashCommand::Resume => {
|
||
self.app_event_tx.send(AppEvent::OpenResumePicker);
|
||
}
|
||
SlashCommand::Fork => {
|
||
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
||
}
|
||
SlashCommand::Init => {
|
||
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
|
||
if init_target.exists() {
|
||
let message = format!(
|
||
"{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it."
|
||
);
|
||
self.add_info_message(message, None);
|
||
return;
|
||
}
|
||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||
self.submit_user_message(INIT_PROMPT.to_string().into());
|
||
}
|
||
SlashCommand::Compact => {
|
||
self.clear_token_usage();
|
||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||
}
|
||
SlashCommand::Review => {
|
||
self.open_review_popup();
|
||
}
|
||
SlashCommand::Rename => {
|
||
self.show_rename_prompt();
|
||
}
|
||
SlashCommand::Model => {
|
||
self.open_model_popup();
|
||
}
|
||
SlashCommand::Personality => {
|
||
self.open_personality_popup();
|
||
}
|
||
SlashCommand::Plan => {
|
||
if !self.collaboration_modes_enabled() {
|
||
self.add_info_message(
|
||
"Collaboration modes are disabled.".to_string(),
|
||
Some("Enable collaboration modes to use /plan.".to_string()),
|
||
);
|
||
return;
|
||
}
|
||
if let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) {
|
||
self.set_collaboration_mask(mask);
|
||
} else {
|
||
self.add_info_message("Plan mode unavailable right now.".to_string(), None);
|
||
}
|
||
}
|
||
SlashCommand::Collab => {
|
||
if !self.collaboration_modes_enabled() {
|
||
self.add_info_message(
|
||
"Collaboration modes are disabled.".to_string(),
|
||
Some("Enable collaboration modes to use /collab.".to_string()),
|
||
);
|
||
return;
|
||
}
|
||
self.open_collaboration_modes_popup();
|
||
}
|
||
SlashCommand::Agent => {
|
||
self.app_event_tx.send(AppEvent::OpenAgentPicker);
|
||
}
|
||
SlashCommand::Approvals => {
|
||
self.open_approvals_popup();
|
||
}
|
||
SlashCommand::Permissions => {
|
||
self.open_permissions_popup();
|
||
}
|
||
SlashCommand::ElevateSandbox => {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
||
let windows_degraded_sandbox_enabled =
|
||
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
|
||
if !windows_degraded_sandbox_enabled
|
||
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
{
|
||
// This command should not be visible/recognized outside degraded mode,
|
||
// but guard anyway in case something dispatches it directly.
|
||
return;
|
||
}
|
||
|
||
let Some(preset) = builtin_approval_presets()
|
||
.into_iter()
|
||
.find(|preset| preset.id == "auto")
|
||
else {
|
||
// Avoid panicking in interactive UI; treat this as a recoverable
|
||
// internal error.
|
||
self.add_error_message(
|
||
"Internal error: missing the 'auto' approval preset.".to_string(),
|
||
);
|
||
return;
|
||
};
|
||
|
||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||
self.add_error_message(err.to_string());
|
||
return;
|
||
}
|
||
|
||
self.otel_manager.counter(
|
||
"codex.windows_sandbox.setup_elevated_sandbox_command",
|
||
1,
|
||
&[],
|
||
);
|
||
self.app_event_tx
|
||
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
let _ = &self.otel_manager;
|
||
// Not supported; on non-Windows this command should never be reachable.
|
||
};
|
||
}
|
||
SlashCommand::Experimental => {
|
||
self.open_experimental_popup();
|
||
}
|
||
SlashCommand::Quit | SlashCommand::Exit => {
|
||
self.request_quit_without_confirmation();
|
||
}
|
||
SlashCommand::Logout => {
|
||
if let Err(e) = codex_core::auth::logout(
|
||
&self.config.codex_home,
|
||
self.config.cli_auth_credentials_store_mode,
|
||
) {
|
||
tracing::error!("failed to logout: {e}");
|
||
}
|
||
self.request_quit_without_confirmation();
|
||
}
|
||
// SlashCommand::Undo => {
|
||
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
||
// }
|
||
SlashCommand::Diff => {
|
||
self.add_diff_in_progress();
|
||
let tx = self.app_event_tx.clone();
|
||
tokio::spawn(async move {
|
||
let text = match get_git_diff().await {
|
||
Ok((is_git_repo, diff_text)) => {
|
||
if is_git_repo {
|
||
diff_text
|
||
} else {
|
||
"`/diff` — _not inside a git repository_".to_string()
|
||
}
|
||
}
|
||
Err(e) => format!("Failed to compute diff: {e}"),
|
||
};
|
||
tx.send(AppEvent::DiffResult(text));
|
||
});
|
||
}
|
||
SlashCommand::Mention => {
|
||
self.insert_str("@");
|
||
}
|
||
SlashCommand::Skills => {
|
||
self.open_skills_menu();
|
||
}
|
||
SlashCommand::Status => {
|
||
self.add_status_output();
|
||
}
|
||
SlashCommand::Ps => {
|
||
self.add_ps_output();
|
||
}
|
||
SlashCommand::Mcp => {
|
||
self.add_mcp_output();
|
||
}
|
||
SlashCommand::Apps => {
|
||
self.add_connectors_output();
|
||
}
|
||
SlashCommand::Rollout => {
|
||
if let Some(path) = self.rollout_path() {
|
||
self.add_info_message(
|
||
format!("Current rollout path: {}", path.display()),
|
||
None,
|
||
);
|
||
} else {
|
||
self.add_info_message("Rollout path is not available yet.".to_string(), None);
|
||
}
|
||
}
|
||
SlashCommand::TestApproval => {
|
||
use codex_core::protocol::EventMsg;
|
||
use std::collections::HashMap;
|
||
|
||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||
use codex_core::protocol::FileChange;
|
||
|
||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||
id: "1".to_string(),
|
||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||
// call_id: "1".to_string(),
|
||
// command: vec!["git".into(), "apply".into()],
|
||
// cwd: self.config.cwd.clone(),
|
||
// reason: Some("test".to_string()),
|
||
// }),
|
||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||
call_id: "1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
changes: HashMap::from([
|
||
(
|
||
PathBuf::from("/tmp/test.txt"),
|
||
FileChange::Add {
|
||
content: "test".to_string(),
|
||
},
|
||
),
|
||
(
|
||
PathBuf::from("/tmp/test2.txt"),
|
||
FileChange::Update {
|
||
unified_diff: "+test\n-test2".to_string(),
|
||
move_path: None,
|
||
},
|
||
),
|
||
]),
|
||
reason: None,
|
||
grant_root: Some(PathBuf::from("/tmp")),
|
||
}),
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
|
||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||
let message = format!(
|
||
"'/{}' is disabled while a task is in progress.",
|
||
cmd.command()
|
||
);
|
||
self.add_to_history(history_cell::new_error_event(message));
|
||
self.request_redraw();
|
||
return;
|
||
}
|
||
|
||
let trimmed = args.trim();
|
||
match cmd {
|
||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
|
||
self.add_error_message("Thread name cannot be empty.".to_string());
|
||
return;
|
||
};
|
||
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
|
||
self.add_boxed_history(Box::new(cell));
|
||
self.request_redraw();
|
||
self.app_event_tx
|
||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||
}
|
||
SlashCommand::Collab | SlashCommand::Plan => {
|
||
let _ = trimmed;
|
||
self.dispatch_command(cmd);
|
||
}
|
||
SlashCommand::Review if !trimmed.is_empty() => {
|
||
self.submit_op(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::Custom {
|
||
instructions: trimmed.to_string(),
|
||
},
|
||
user_facing_hint: None,
|
||
},
|
||
});
|
||
}
|
||
_ => self.dispatch_command(cmd),
|
||
}
|
||
}
|
||
|
||
fn show_rename_prompt(&mut self) {
|
||
let tx = self.app_event_tx.clone();
|
||
let has_name = self
|
||
.thread_name
|
||
.as_ref()
|
||
.is_some_and(|name| !name.is_empty());
|
||
let title = if has_name {
|
||
"Rename thread"
|
||
} else {
|
||
"Name thread"
|
||
};
|
||
let thread_id = self.thread_id;
|
||
let view = CustomPromptView::new(
|
||
title.to_string(),
|
||
"Type a name and press Enter".to_string(),
|
||
None,
|
||
Box::new(move |name: String| {
|
||
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
|
||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
|
||
)));
|
||
return;
|
||
};
|
||
let cell = Self::rename_confirmation_cell(&name, thread_id);
|
||
tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||
tx.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||
}),
|
||
);
|
||
|
||
self.bottom_pane.show_view(Box::new(view));
|
||
}
|
||
|
||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||
self.bottom_pane.handle_paste(text);
|
||
}
|
||
|
||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||
if self.bottom_pane.flush_paste_burst_if_due() {
|
||
// A paste just flushed; request an immediate redraw and skip this frame.
|
||
self.request_redraw();
|
||
true
|
||
} else if self.bottom_pane.is_in_paste_burst() {
|
||
// While capturing a burst, schedule a follow-up tick and skip this frame
|
||
// to avoid redundant renders between ticks.
|
||
frame_requester.schedule_frame_in(
|
||
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
|
||
);
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn flush_active_cell(&mut self) {
|
||
if let Some(active) = self.active_cell.take() {
|
||
self.needs_final_message_separator = true;
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
|
||
}
|
||
}
|
||
|
||
pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
|
||
self.add_boxed_history(Box::new(cell));
|
||
}
|
||
|
||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||
// Keep the placeholder session header as the active cell until real session info arrives,
|
||
// so we can merge headers instead of committing a duplicate box to history.
|
||
let keep_placeholder_header_active = !self.is_session_configured()
|
||
&& self
|
||
.active_cell
|
||
.as_ref()
|
||
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
|
||
|
||
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
|
||
// Only break exec grouping if the cell renders visible lines.
|
||
self.flush_active_cell();
|
||
self.needs_final_message_separator = true;
|
||
}
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||
}
|
||
|
||
fn queue_user_message(&mut self, user_message: UserMessage) {
|
||
if !self.is_session_configured()
|
||
|| self.bottom_pane.is_task_running()
|
||
|| self.is_review_mode
|
||
{
|
||
self.queued_user_messages.push_back(user_message);
|
||
self.refresh_queued_user_messages();
|
||
} else {
|
||
self.submit_user_message(user_message);
|
||
}
|
||
}
|
||
|
||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||
if !self.is_session_configured() {
|
||
tracing::warn!("cannot submit user message before session is configured; queueing");
|
||
self.queued_user_messages.push_front(user_message);
|
||
self.refresh_queued_user_messages();
|
||
return;
|
||
}
|
||
|
||
let UserMessage {
|
||
text,
|
||
local_images,
|
||
text_elements,
|
||
mention_paths,
|
||
} = user_message;
|
||
if text.is_empty() && local_images.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let mut items: Vec<UserInput> = Vec::new();
|
||
|
||
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
|
||
if let Some(stripped) = text.strip_prefix('!') {
|
||
let cmd = stripped.trim();
|
||
if cmd.is_empty() {
|
||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_info_event(
|
||
USER_SHELL_COMMAND_HELP_TITLE.to_string(),
|
||
Some(USER_SHELL_COMMAND_HELP_HINT.to_string()),
|
||
),
|
||
)));
|
||
return;
|
||
}
|
||
self.submit_op(Op::RunUserShellCommand {
|
||
command: cmd.to_string(),
|
||
});
|
||
return;
|
||
}
|
||
|
||
for image in &local_images {
|
||
items.push(UserInput::LocalImage {
|
||
path: image.path.clone(),
|
||
});
|
||
}
|
||
|
||
if !text.is_empty() {
|
||
items.push(UserInput::Text {
|
||
text: text.clone(),
|
||
text_elements: text_elements.clone(),
|
||
});
|
||
}
|
||
|
||
let mentions = collect_tool_mentions(&text, &mention_paths);
|
||
let mut skill_names_lower: HashSet<String> = HashSet::new();
|
||
|
||
if let Some(skills) = self.bottom_pane.skills() {
|
||
skill_names_lower = skills
|
||
.iter()
|
||
.map(|skill| skill.name.to_ascii_lowercase())
|
||
.collect();
|
||
let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills);
|
||
for skill in skill_mentions {
|
||
items.push(UserInput::Skill {
|
||
name: skill.name.clone(),
|
||
path: skill.path.clone(),
|
||
});
|
||
}
|
||
}
|
||
|
||
if let Some(apps) = self.connectors_for_mentions() {
|
||
let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower);
|
||
for app in app_mentions {
|
||
let app_id = app.id.as_str();
|
||
items.push(UserInput::Mention {
|
||
name: app.name.clone(),
|
||
path: format!("app://{app_id}"),
|
||
});
|
||
}
|
||
}
|
||
|
||
let effective_mode = self.effective_collaboration_mode();
|
||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||
self.active_collaboration_mask
|
||
.as_ref()
|
||
.map(|_| effective_mode.clone())
|
||
} else {
|
||
None
|
||
};
|
||
let personality = self
|
||
.config
|
||
.model_personality
|
||
.filter(|_| self.config.features.enabled(Feature::Personality))
|
||
.filter(|_| self.current_model_supports_personality());
|
||
let op = Op::UserTurn {
|
||
items,
|
||
cwd: self.config.cwd.clone(),
|
||
approval_policy: self.config.approval_policy.value(),
|
||
sandbox_policy: self.config.sandbox_policy.get().clone(),
|
||
model: effective_mode.model().to_string(),
|
||
effort: effective_mode.reasoning_effort(),
|
||
summary: self.config.model_reasoning_summary,
|
||
final_output_json_schema: None,
|
||
collaboration_mode,
|
||
personality,
|
||
};
|
||
|
||
self.codex_op_tx.send(op).unwrap_or_else(|e| {
|
||
tracing::error!("failed to send message: {e}");
|
||
});
|
||
|
||
// Persist the text to cross-session message history.
|
||
if !text.is_empty() {
|
||
self.codex_op_tx
|
||
.send(Op::AddToHistory { text: text.clone() })
|
||
.unwrap_or_else(|e| {
|
||
tracing::error!("failed to send AddHistory op: {e}");
|
||
});
|
||
}
|
||
|
||
// Only show the text portion in conversation history.
|
||
if !text.is_empty() {
|
||
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
|
||
self.add_to_history(history_cell::new_user_prompt(
|
||
text,
|
||
text_elements,
|
||
local_image_paths,
|
||
));
|
||
}
|
||
|
||
self.needs_final_message_separator = false;
|
||
}
|
||
|
||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||
/// resuming an existing session. This approximates the live event flow and
|
||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||
/// avoid triggering side effects. Event ids are passed as `None` to
|
||
/// distinguish replayed events from live ones.
|
||
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
|
||
for msg in events {
|
||
if matches!(
|
||
msg,
|
||
EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_)
|
||
) {
|
||
continue;
|
||
}
|
||
// `id: None` indicates a synthetic/fake id coming from replay.
|
||
self.dispatch_event_msg(None, msg, true);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||
let Event { id, msg } = event;
|
||
self.dispatch_event_msg(Some(id), msg, false);
|
||
}
|
||
|
||
pub(crate) fn handle_codex_event_replay(&mut self, event: Event) {
|
||
let Event { msg, .. } = event;
|
||
if matches!(msg, EventMsg::ShutdownComplete) {
|
||
return;
|
||
}
|
||
self.dispatch_event_msg(None, msg, true);
|
||
}
|
||
|
||
/// Dispatch a protocol `EventMsg` to the appropriate handler.
|
||
///
|
||
/// `id` is `Some` for live events and `None` for replayed events from
|
||
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
|
||
/// that must not be used to correlate follow-up actions.
|
||
fn dispatch_event_msg(&mut self, id: Option<String>, msg: EventMsg, from_replay: bool) {
|
||
let is_stream_error = matches!(&msg, EventMsg::StreamError(_));
|
||
if !is_stream_error {
|
||
self.restore_retry_status_header_if_present();
|
||
}
|
||
|
||
match msg {
|
||
EventMsg::AgentMessageDelta(_)
|
||
| EventMsg::AgentReasoningDelta(_)
|
||
| EventMsg::TerminalInteraction(_)
|
||
| EventMsg::ExecCommandOutputDelta(_) => {}
|
||
_ => {
|
||
tracing::trace!("handle_codex_event: {:?}", msg);
|
||
}
|
||
}
|
||
|
||
match msg {
|
||
EventMsg::SessionConfigured(e) => self.on_session_configured(e),
|
||
EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e),
|
||
EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message),
|
||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||
self.on_agent_message_delta(delta)
|
||
}
|
||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta })
|
||
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
|
||
delta,
|
||
}) => self.on_agent_reasoning_delta(delta),
|
||
EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(),
|
||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||
self.on_agent_reasoning_delta(text);
|
||
self.on_agent_reasoning_final();
|
||
}
|
||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||
EventMsg::TurnStarted(_) => self.on_task_started(),
|
||
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
|
||
self.on_task_complete(last_agent_message, from_replay)
|
||
}
|
||
EventMsg::TokenCount(ev) => {
|
||
self.set_token_info(ev.info);
|
||
self.on_rate_limit_snapshot(ev.rate_limits);
|
||
}
|
||
EventMsg::Warning(WarningEvent { message }) => self.on_warning(message),
|
||
EventMsg::Error(ErrorEvent {
|
||
message,
|
||
codex_error_info,
|
||
}) => {
|
||
if let Some(info) = codex_error_info
|
||
&& let Some(kind) = rate_limit_error_kind(&info)
|
||
{
|
||
match kind {
|
||
RateLimitErrorKind::ModelCap {
|
||
model,
|
||
reset_after_seconds,
|
||
} => self.on_model_cap_error(model, reset_after_seconds),
|
||
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
|
||
self.on_error(message)
|
||
}
|
||
}
|
||
} else {
|
||
self.on_error(message);
|
||
}
|
||
}
|
||
EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev),
|
||
EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev),
|
||
EventMsg::TurnAborted(ev) => match ev.reason {
|
||
TurnAbortReason::Interrupted => {
|
||
self.on_interrupted_turn(ev.reason);
|
||
}
|
||
TurnAbortReason::Replaced => {
|
||
self.on_error("Turn aborted: replaced by a new task".to_owned())
|
||
}
|
||
TurnAbortReason::ReviewEnded => {
|
||
self.on_interrupted_turn(ev.reason);
|
||
}
|
||
},
|
||
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
|
||
EventMsg::ExecApprovalRequest(ev) => {
|
||
// For replayed events, synthesize an empty id (these should not occur).
|
||
self.on_exec_approval_request(id.unwrap_or_default(), ev)
|
||
}
|
||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||
self.on_apply_patch_approval_request(id.unwrap_or_default(), ev)
|
||
}
|
||
EventMsg::ElicitationRequest(ev) => {
|
||
self.on_elicitation_request(ev);
|
||
}
|
||
EventMsg::RequestUserInput(ev) => {
|
||
self.on_request_user_input(ev);
|
||
}
|
||
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
|
||
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
|
||
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
|
||
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
|
||
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
|
||
EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
|
||
EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev),
|
||
EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
|
||
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
|
||
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
|
||
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
|
||
EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
|
||
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
|
||
EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev),
|
||
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
|
||
EventMsg::SkillsUpdateAvailable => {
|
||
self.submit_op(Op::ListSkills {
|
||
cwds: Vec::new(),
|
||
force_reload: true,
|
||
});
|
||
}
|
||
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
|
||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
|
||
EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev),
|
||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||
self.on_background_event(message)
|
||
}
|
||
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
|
||
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
|
||
EventMsg::StreamError(StreamErrorEvent {
|
||
message,
|
||
additional_details,
|
||
..
|
||
}) => self.on_stream_error(message, additional_details),
|
||
EventMsg::UserMessage(ev) => {
|
||
if from_replay {
|
||
self.on_user_message_event(ev);
|
||
}
|
||
}
|
||
EventMsg::EnteredReviewMode(review_request) => {
|
||
self.on_entered_review_mode(review_request, from_replay)
|
||
}
|
||
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
|
||
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
|
||
EventMsg::CollabAgentSpawnBegin(_) => {}
|
||
EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)),
|
||
EventMsg::CollabAgentInteractionBegin(_) => {}
|
||
EventMsg::CollabAgentInteractionEnd(ev) => {
|
||
self.on_collab_event(collab::interaction_end(ev))
|
||
}
|
||
EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)),
|
||
EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)),
|
||
EventMsg::CollabCloseBegin(_) => {}
|
||
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
|
||
EventMsg::ThreadRolledBack(_) => {}
|
||
EventMsg::RawResponseItem(_)
|
||
| EventMsg::ItemStarted(_)
|
||
| EventMsg::ItemCompleted(_)
|
||
| EventMsg::AgentMessageContentDelta(_)
|
||
| EventMsg::ReasoningContentDelta(_)
|
||
| EventMsg::ReasoningRawContentDelta(_)
|
||
| EventMsg::DynamicToolCallRequest(_) => {}
|
||
}
|
||
}
|
||
|
||
fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) {
|
||
// Enter review mode and emit a concise banner
|
||
if self.pre_review_token_info.is_none() {
|
||
self.pre_review_token_info = Some(self.token_info.clone());
|
||
}
|
||
// Avoid toggling running state for replayed history events on resume.
|
||
if !from_replay && !self.bottom_pane.is_task_running() {
|
||
self.bottom_pane.set_task_running(true);
|
||
}
|
||
self.is_review_mode = true;
|
||
let hint = review
|
||
.user_facing_hint
|
||
.unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target));
|
||
let banner = format!(">> Code review started: {hint} <<");
|
||
self.add_to_history(history_cell::new_review_status_line(banner));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) {
|
||
// Leave review mode; if output is present, flush pending stream + show results.
|
||
if let Some(output) = review.review_output {
|
||
self.flush_answer_stream_with_separator();
|
||
self.flush_interrupt_queue();
|
||
self.flush_active_cell();
|
||
|
||
if output.findings.is_empty() {
|
||
let explanation = output.overall_explanation.trim().to_string();
|
||
if explanation.is_empty() {
|
||
tracing::error!("Reviewer failed to output a response.");
|
||
self.add_to_history(history_cell::new_error_event(
|
||
"Reviewer failed to output a response.".to_owned(),
|
||
));
|
||
} else {
|
||
// Show explanation when there are no structured findings.
|
||
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
|
||
append_markdown(&explanation, None, &mut rendered);
|
||
let body_cell = AgentMessageCell::new(rendered, false);
|
||
self.app_event_tx
|
||
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
|
||
}
|
||
}
|
||
// Final message is rendered as part of the AgentMessage.
|
||
}
|
||
|
||
self.is_review_mode = false;
|
||
self.restore_pre_review_token_info();
|
||
// Append a finishing banner at the end of this turn.
|
||
self.add_to_history(history_cell::new_review_status_line(
|
||
"<< Code review finished >>".to_string(),
|
||
));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||
if !event.message.trim().is_empty() {
|
||
self.add_to_history(history_cell::new_user_prompt(
|
||
event.message,
|
||
event.text_elements,
|
||
event.local_images,
|
||
));
|
||
}
|
||
|
||
// User messages reset separator state so the next agent response doesn't add a stray break.
|
||
self.needs_final_message_separator = false;
|
||
}
|
||
|
||
/// Exit the UI immediately without waiting for shutdown.
|
||
///
|
||
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
|
||
/// this is mainly a fallback for shutdown completion or emergency exits.
|
||
fn request_immediate_exit(&self) {
|
||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||
}
|
||
|
||
/// Request a shutdown-first quit.
|
||
///
|
||
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
|
||
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
|
||
fn request_quit_without_confirmation(&self) {
|
||
self.app_event_tx
|
||
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
||
}
|
||
|
||
fn request_redraw(&mut self) {
|
||
self.frame_requester.schedule_frame();
|
||
}
|
||
|
||
fn bump_active_cell_revision(&mut self) {
|
||
// Wrapping avoids overflow; wraparound would require 2^64 bumps and at
|
||
// worst causes a one-time cache-key collision.
|
||
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
|
||
}
|
||
|
||
fn notify(&mut self, notification: Notification) {
|
||
if !notification.allowed_for(&self.config.tui_notifications) {
|
||
return;
|
||
}
|
||
self.pending_notification = Some(notification);
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) {
|
||
if let Some(notif) = self.pending_notification.take() {
|
||
tui.notify(notif.display());
|
||
}
|
||
}
|
||
|
||
/// Mark the active cell as failed (✗) and flush it into history.
|
||
fn finalize_active_cell_as_failed(&mut self) {
|
||
if let Some(mut cell) = self.active_cell.take() {
|
||
// Insert finalized cell into history and keep grouping consistent.
|
||
if let Some(exec) = cell.as_any_mut().downcast_mut::<ExecCell>() {
|
||
exec.mark_failed();
|
||
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
|
||
tool.mark_failed();
|
||
}
|
||
self.add_boxed_history(cell);
|
||
}
|
||
}
|
||
|
||
// If idle and there are queued inputs, submit exactly one to start the next turn.
|
||
fn maybe_send_next_queued_input(&mut self) {
|
||
if self.bottom_pane.is_task_running() {
|
||
return;
|
||
}
|
||
if let Some(user_message) = self.queued_user_messages.pop_front() {
|
||
self.submit_user_message(user_message);
|
||
}
|
||
// Update the list to reflect the remaining queued messages (if any).
|
||
self.refresh_queued_user_messages();
|
||
}
|
||
|
||
/// Rebuild and update the queued user messages from the current queue.
|
||
fn refresh_queued_user_messages(&mut self) {
|
||
let messages: Vec<String> = self
|
||
.queued_user_messages
|
||
.iter()
|
||
.map(|m| m.text.clone())
|
||
.collect();
|
||
self.bottom_pane.set_queued_user_messages(messages);
|
||
}
|
||
|
||
pub(crate) fn add_diff_in_progress(&mut self) {
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn on_diff_complete(&mut self) {
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn add_status_output(&mut self) {
|
||
let default_usage = TokenUsage::default();
|
||
let token_info = self.token_info.as_ref();
|
||
let total_usage = token_info
|
||
.map(|ti| &ti.total_token_usage)
|
||
.unwrap_or(&default_usage);
|
||
let collaboration_mode = self.collaboration_mode_label();
|
||
let reasoning_effort_override = Some(self.effective_reasoning_effort());
|
||
self.add_to_history(crate::status::new_status_output(
|
||
&self.config,
|
||
self.auth_manager.as_ref(),
|
||
token_info,
|
||
total_usage,
|
||
&self.thread_id,
|
||
self.thread_name.clone(),
|
||
self.forked_from,
|
||
self.rate_limit_snapshot.as_ref(),
|
||
self.plan_type,
|
||
Local::now(),
|
||
self.model_display_name(),
|
||
collaboration_mode,
|
||
reasoning_effort_override,
|
||
));
|
||
}
|
||
|
||
pub(crate) fn add_ps_output(&mut self) {
|
||
let processes = self
|
||
.unified_exec_processes
|
||
.iter()
|
||
.map(|process| history_cell::UnifiedExecProcessDetails {
|
||
command_display: process.command_display.clone(),
|
||
recent_chunks: process.recent_chunks.clone(),
|
||
})
|
||
.collect();
|
||
self.add_to_history(history_cell::new_unified_exec_processes_output(processes));
|
||
}
|
||
|
||
fn stop_rate_limit_poller(&mut self) {
|
||
if let Some(handle) = self.rate_limit_poller.take() {
|
||
handle.abort();
|
||
}
|
||
}
|
||
|
||
fn prefetch_connectors(&mut self) {
|
||
if !self.connectors_enabled() {
|
||
return;
|
||
}
|
||
if matches!(self.connectors_cache, ConnectorsCacheState::Loading) {
|
||
return;
|
||
}
|
||
|
||
self.connectors_cache = ConnectorsCacheState::Loading;
|
||
let config = self.config.clone();
|
||
let app_event_tx = self.app_event_tx.clone();
|
||
tokio::spawn(async move {
|
||
let result: Result<ConnectorsSnapshot, anyhow::Error> = async {
|
||
let connectors = connectors::list_connectors(&config).await?;
|
||
Ok(ConnectorsSnapshot { connectors })
|
||
}
|
||
.await;
|
||
let result = result.map_err(|err| format!("Failed to load apps: {err}"));
|
||
app_event_tx.send(AppEvent::ConnectorsLoaded(result));
|
||
});
|
||
}
|
||
|
||
fn prefetch_rate_limits(&mut self) {
|
||
self.stop_rate_limit_poller();
|
||
|
||
if !self
|
||
.auth_manager
|
||
.auth_cached()
|
||
.as_ref()
|
||
.is_some_and(CodexAuth::is_chatgpt_auth)
|
||
{
|
||
return;
|
||
}
|
||
|
||
let base_url = self.config.chatgpt_base_url.clone();
|
||
let app_event_tx = self.app_event_tx.clone();
|
||
let auth_manager = Arc::clone(&self.auth_manager);
|
||
|
||
let handle = tokio::spawn(async move {
|
||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||
|
||
loop {
|
||
if let Some(auth) = auth_manager.auth().await
|
||
&& auth.is_chatgpt_auth()
|
||
&& let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await
|
||
{
|
||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||
}
|
||
interval.tick().await;
|
||
}
|
||
});
|
||
|
||
self.rate_limit_poller = Some(handle);
|
||
}
|
||
|
||
fn lower_cost_preset(&self) -> Option<ModelPreset> {
|
||
let models = self.models_manager.try_list_models(&self.config).ok()?;
|
||
models
|
||
.iter()
|
||
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
|
||
.cloned()
|
||
}
|
||
|
||
fn rate_limit_switch_prompt_hidden(&self) -> bool {
|
||
self.config
|
||
.notices
|
||
.hide_rate_limit_model_nudge
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn maybe_show_pending_rate_limit_prompt(&mut self) {
|
||
if self.rate_limit_switch_prompt_hidden() {
|
||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||
return;
|
||
}
|
||
if !matches!(
|
||
self.rate_limit_switch_prompt,
|
||
RateLimitSwitchPromptState::Pending
|
||
) {
|
||
return;
|
||
}
|
||
if let Some(preset) = self.lower_cost_preset() {
|
||
self.open_rate_limit_switch_prompt(preset);
|
||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;
|
||
} else {
|
||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||
}
|
||
}
|
||
|
||
fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {
|
||
let switch_model = preset.model.to_string();
|
||
let display_name = preset.display_name.to_string();
|
||
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
|
||
|
||
let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
sandbox_policy: None,
|
||
windows_sandbox_level: None,
|
||
model: Some(switch_model.clone()),
|
||
effort: Some(Some(default_effort)),
|
||
summary: None,
|
||
collaboration_mode: None,
|
||
personality: None,
|
||
}));
|
||
tx.send(AppEvent::UpdateModel(switch_model.clone()));
|
||
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
|
||
})];
|
||
|
||
let keep_actions: Vec<SelectionAction> = Vec::new();
|
||
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
|
||
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
|
||
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
|
||
})];
|
||
let description = if preset.description.is_empty() {
|
||
Some("Uses fewer credits for upcoming turns.".to_string())
|
||
} else {
|
||
Some(preset.description)
|
||
};
|
||
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: format!("Switch to {display_name}"),
|
||
description,
|
||
selected_description: None,
|
||
is_current: false,
|
||
actions: switch_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Keep current model".to_string(),
|
||
description: None,
|
||
selected_description: None,
|
||
is_current: false,
|
||
actions: keep_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Keep current model (never show again)".to_string(),
|
||
description: Some(
|
||
"Hide future rate limit reminders about switching models.".to_string(),
|
||
),
|
||
selected_description: None,
|
||
is_current: false,
|
||
actions: never_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Approaching rate limits".to_string()),
|
||
subtitle: Some(format!("Switch to {display_name} for lower credit usage?")),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
/// Open a popup to choose a quick auto model. Selecting "All models"
|
||
/// opens the full picker with every available preset.
|
||
pub(crate) fn open_model_popup(&mut self) {
|
||
if !self.is_session_configured() {
|
||
self.add_info_message(
|
||
"Model selection is disabled until startup completes.".to_string(),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
|
||
let presets: Vec<ModelPreset> = match self.models_manager.try_list_models(&self.config) {
|
||
Ok(models) => models,
|
||
Err(_) => {
|
||
self.add_info_message(
|
||
"Models are being updated; please try /model again in a moment.".to_string(),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
self.open_model_popup_with_presets(presets);
|
||
}
|
||
|
||
pub(crate) fn open_personality_popup(&mut self) {
|
||
if !self.is_session_configured() {
|
||
self.add_info_message(
|
||
"Personality selection is disabled until startup completes.".to_string(),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
if !self.current_model_supports_personality() {
|
||
let current_model = self.current_model();
|
||
self.add_error_message(format!(
|
||
"Current model ({current_model}) doesn't support personalities. Try /model to pick a different model."
|
||
));
|
||
return;
|
||
}
|
||
self.open_personality_popup_for_current_model();
|
||
}
|
||
|
||
fn open_personality_popup_for_current_model(&mut self) {
|
||
let current_personality = self
|
||
.config
|
||
.model_personality
|
||
.unwrap_or(Personality::Friendly);
|
||
let personalities = [Personality::Friendly, Personality::Pragmatic];
|
||
let supports_personality = self.current_model_supports_personality();
|
||
|
||
let items: Vec<SelectionItem> = personalities
|
||
.into_iter()
|
||
.map(|personality| {
|
||
let name = Self::personality_label(personality).to_string();
|
||
let description = Some(Self::personality_description(personality).to_string());
|
||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
sandbox_policy: None,
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
collaboration_mode: None,
|
||
windows_sandbox_level: None,
|
||
personality: Some(personality),
|
||
}));
|
||
tx.send(AppEvent::UpdatePersonality(personality));
|
||
tx.send(AppEvent::PersistPersonalitySelection { personality });
|
||
})];
|
||
SelectionItem {
|
||
name,
|
||
description,
|
||
is_current: current_personality == personality,
|
||
is_disabled: !supports_personality,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from("Select Personality".bold()));
|
||
header.push(Line::from(
|
||
"Choose a communication style for Codex. Disable in /experimental.".dim(),
|
||
));
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
header: Box::new(header),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
|
||
let title = title.to_string();
|
||
let subtitle = subtitle.to_string();
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from(title.bold()));
|
||
header.push(Line::from(subtitle.dim()));
|
||
if let Some(warning) = self.model_menu_warning_line() {
|
||
header.push(warning);
|
||
}
|
||
Box::new(header)
|
||
}
|
||
|
||
fn model_menu_warning_line(&self) -> Option<Line<'static>> {
|
||
let base_url = self.custom_openai_base_url()?;
|
||
let warning = format!(
|
||
"Warning: OPENAI_BASE_URL is set to {base_url}. Selecting models may not be supported or work properly."
|
||
);
|
||
Some(Line::from(warning.red()))
|
||
}
|
||
|
||
fn custom_openai_base_url(&self) -> Option<String> {
|
||
if !self.config.model_provider.is_openai() {
|
||
return None;
|
||
}
|
||
|
||
let base_url = self.config.model_provider.base_url.as_ref()?;
|
||
let trimmed = base_url.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let normalized = trimmed.trim_end_matches('/');
|
||
if normalized == DEFAULT_OPENAI_BASE_URL {
|
||
return None;
|
||
}
|
||
|
||
Some(trimmed.to_string())
|
||
}
|
||
|
||
pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec<ModelPreset>) {
|
||
let presets: Vec<ModelPreset> = presets
|
||
.into_iter()
|
||
.filter(|preset| preset.show_in_picker)
|
||
.collect();
|
||
|
||
let current_model = self.current_model();
|
||
let current_label = presets
|
||
.iter()
|
||
.find(|preset| preset.model.as_str() == current_model)
|
||
.map(|preset| preset.display_name.to_string())
|
||
.unwrap_or_else(|| self.model_display_name().to_string());
|
||
|
||
let (mut auto_presets, other_presets): (Vec<ModelPreset>, Vec<ModelPreset>) = presets
|
||
.into_iter()
|
||
.partition(|preset| Self::is_auto_model(&preset.model));
|
||
|
||
if auto_presets.is_empty() {
|
||
self.open_all_models_popup(other_presets);
|
||
return;
|
||
}
|
||
|
||
auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model));
|
||
|
||
let mut items: Vec<SelectionItem> = auto_presets
|
||
.into_iter()
|
||
.map(|preset| {
|
||
let description =
|
||
(!preset.description.is_empty()).then_some(preset.description.clone());
|
||
let model = preset.model.clone();
|
||
let actions = Self::model_selection_actions(
|
||
model.clone(),
|
||
Some(preset.default_reasoning_effort),
|
||
);
|
||
SelectionItem {
|
||
name: preset.display_name.clone(),
|
||
description,
|
||
is_current: model.as_str() == current_model,
|
||
is_default: preset.is_default,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
if !other_presets.is_empty() {
|
||
let all_models = other_presets;
|
||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenAllModelsPopup {
|
||
models: all_models.clone(),
|
||
});
|
||
})];
|
||
|
||
let is_current = !items.iter().any(|item| item.is_current);
|
||
let description = Some(format!(
|
||
"Choose a specific model and reasoning level (current: {current_label})"
|
||
));
|
||
|
||
items.push(SelectionItem {
|
||
name: "All models".to_string(),
|
||
description,
|
||
is_current,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
let header = self.model_menu_header(
|
||
"Select Model",
|
||
"Pick a quick auto mode or browse all models.",
|
||
);
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn is_auto_model(model: &str) -> bool {
|
||
model.starts_with("codex-auto-")
|
||
}
|
||
|
||
fn auto_model_order(model: &str) -> usize {
|
||
match model {
|
||
"codex-auto-fast" => 0,
|
||
"codex-auto-balanced" => 1,
|
||
"codex-auto-thorough" => 2,
|
||
_ => 3,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn open_all_models_popup(&mut self, presets: Vec<ModelPreset>) {
|
||
if presets.is_empty() {
|
||
self.add_info_message(
|
||
"No additional models are available right now.".to_string(),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
|
||
let mut items: Vec<SelectionItem> = Vec::new();
|
||
for preset in presets.into_iter() {
|
||
let description =
|
||
(!preset.description.is_empty()).then_some(preset.description.to_string());
|
||
let is_current = preset.model.as_str() == self.current_model();
|
||
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
|
||
let preset_for_action = preset.clone();
|
||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
let preset_for_event = preset_for_action.clone();
|
||
tx.send(AppEvent::OpenReasoningPopup {
|
||
model: preset_for_event,
|
||
});
|
||
})];
|
||
items.push(SelectionItem {
|
||
name: preset.display_name.clone(),
|
||
description,
|
||
is_current,
|
||
is_default: preset.is_default,
|
||
actions,
|
||
dismiss_on_select: single_supported_effort,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
let header = self.model_menu_header(
|
||
"Select Model and Effort",
|
||
"Access legacy models by running codex -m <model_name> or in your config.toml",
|
||
);
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()),
|
||
items,
|
||
header,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) fn open_collaboration_modes_popup(&mut self) {
|
||
let presets = collaboration_modes::presets_for_tui(self.models_manager.as_ref());
|
||
if presets.is_empty() {
|
||
self.add_info_message(
|
||
"No collaboration modes are available right now.".to_string(),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
|
||
let current_kind = self
|
||
.active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.mode)
|
||
.or_else(|| {
|
||
collaboration_modes::default_mask(self.models_manager.as_ref())
|
||
.and_then(|mask| mask.mode)
|
||
});
|
||
let items: Vec<SelectionItem> = presets
|
||
.into_iter()
|
||
.map(|mask| {
|
||
let name = mask.name.clone();
|
||
let is_current = current_kind == mask.mode;
|
||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::UpdateCollaborationMode(mask.clone()));
|
||
})];
|
||
SelectionItem {
|
||
name,
|
||
is_current,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Select Collaboration Mode".to_string()),
|
||
subtitle: Some("Pick a collaboration preset.".to_string()),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn model_selection_actions(
|
||
model_for_action: String,
|
||
effort_for_action: Option<ReasoningEffortConfig>,
|
||
) -> Vec<SelectionAction> {
|
||
vec![Box::new(move |tx| {
|
||
let effort_label = effort_for_action
|
||
.map(|effort| effort.to_string())
|
||
.unwrap_or_else(|| "default".to_string());
|
||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
sandbox_policy: None,
|
||
windows_sandbox_level: None,
|
||
model: Some(model_for_action.clone()),
|
||
effort: Some(effort_for_action),
|
||
summary: None,
|
||
collaboration_mode: None,
|
||
personality: None,
|
||
}));
|
||
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
|
||
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
|
||
tx.send(AppEvent::PersistModelSelection {
|
||
model: model_for_action.clone(),
|
||
effort: effort_for_action,
|
||
});
|
||
tracing::info!(
|
||
"Selected model: {}, Selected effort: {}",
|
||
model_for_action,
|
||
effort_label
|
||
);
|
||
})]
|
||
}
|
||
|
||
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
|
||
pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) {
|
||
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
|
||
let supported = preset.supported_reasoning_efforts;
|
||
|
||
let warn_effort = if supported
|
||
.iter()
|
||
.any(|option| option.effort == ReasoningEffortConfig::XHigh)
|
||
{
|
||
Some(ReasoningEffortConfig::XHigh)
|
||
} else if supported
|
||
.iter()
|
||
.any(|option| option.effort == ReasoningEffortConfig::High)
|
||
{
|
||
Some(ReasoningEffortConfig::High)
|
||
} else {
|
||
None
|
||
};
|
||
let warning_text = warn_effort.map(|effort| {
|
||
let effort_label = Self::reasoning_effort_label(effort);
|
||
format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.")
|
||
});
|
||
let warn_for_model = preset.model.starts_with("gpt-5.1-codex")
|
||
|| preset.model.starts_with("gpt-5.1-codex-max")
|
||
|| preset.model.starts_with("gpt-5.2");
|
||
|
||
struct EffortChoice {
|
||
stored: Option<ReasoningEffortConfig>,
|
||
display: ReasoningEffortConfig,
|
||
}
|
||
let mut choices: Vec<EffortChoice> = Vec::new();
|
||
for effort in ReasoningEffortConfig::iter() {
|
||
if supported.iter().any(|option| option.effort == effort) {
|
||
choices.push(EffortChoice {
|
||
stored: Some(effort),
|
||
display: effort,
|
||
});
|
||
}
|
||
}
|
||
if choices.is_empty() {
|
||
choices.push(EffortChoice {
|
||
stored: Some(default_effort),
|
||
display: default_effort,
|
||
});
|
||
}
|
||
|
||
if choices.len() == 1 {
|
||
if let Some(effort) = choices.first().and_then(|c| c.stored) {
|
||
self.apply_model_and_effort(preset.model, Some(effort));
|
||
} else {
|
||
self.apply_model_and_effort(preset.model, None);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let default_choice: Option<ReasoningEffortConfig> = choices
|
||
.iter()
|
||
.any(|choice| choice.stored == Some(default_effort))
|
||
.then_some(Some(default_effort))
|
||
.flatten()
|
||
.or_else(|| choices.iter().find_map(|choice| choice.stored))
|
||
.or(Some(default_effort));
|
||
|
||
let model_slug = preset.model.to_string();
|
||
let is_current_model = self.current_model() == preset.model.as_str();
|
||
let highlight_choice = if is_current_model {
|
||
self.effective_reasoning_effort()
|
||
} else {
|
||
default_choice
|
||
};
|
||
let selection_choice = highlight_choice.or(default_choice);
|
||
let initial_selected_idx = choices
|
||
.iter()
|
||
.position(|choice| choice.stored == selection_choice)
|
||
.or_else(|| {
|
||
selection_choice
|
||
.and_then(|effort| choices.iter().position(|choice| choice.display == effort))
|
||
});
|
||
let mut items: Vec<SelectionItem> = Vec::new();
|
||
for choice in choices.iter() {
|
||
let effort = choice.display;
|
||
let mut effort_label = Self::reasoning_effort_label(effort).to_string();
|
||
if choice.stored == default_choice {
|
||
effort_label.push_str(" (default)");
|
||
}
|
||
|
||
let description = choice
|
||
.stored
|
||
.and_then(|effort| {
|
||
supported
|
||
.iter()
|
||
.find(|option| option.effort == effort)
|
||
.map(|option| option.description.to_string())
|
||
})
|
||
.filter(|text| !text.is_empty());
|
||
|
||
let show_warning = warn_for_model && warn_effort == Some(effort);
|
||
let selected_description = if show_warning {
|
||
warning_text.as_ref().map(|warning_message| {
|
||
description.as_ref().map_or_else(
|
||
|| warning_message.clone(),
|
||
|d| format!("{d}\n{warning_message}"),
|
||
)
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let model_for_action = model_slug.clone();
|
||
let actions = Self::model_selection_actions(model_for_action, choice.stored);
|
||
|
||
items.push(SelectionItem {
|
||
name: effort_label,
|
||
description,
|
||
selected_description,
|
||
is_current: is_current_model && choice.stored == highlight_choice,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from(
|
||
format!("Select Reasoning Level for {model_slug}").bold(),
|
||
));
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
header: Box::new(header),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
initial_selected_idx,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str {
|
||
match effort {
|
||
ReasoningEffortConfig::None => "None",
|
||
ReasoningEffortConfig::Minimal => "Minimal",
|
||
ReasoningEffortConfig::Low => "Low",
|
||
ReasoningEffortConfig::Medium => "Medium",
|
||
ReasoningEffortConfig::High => "High",
|
||
ReasoningEffortConfig::XHigh => "Extra high",
|
||
}
|
||
}
|
||
|
||
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
|
||
self.app_event_tx
|
||
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
sandbox_policy: None,
|
||
windows_sandbox_level: None,
|
||
model: Some(model.clone()),
|
||
effort: Some(effort),
|
||
summary: None,
|
||
collaboration_mode: None,
|
||
personality: None,
|
||
}));
|
||
self.app_event_tx.send(AppEvent::UpdateModel(model.clone()));
|
||
self.app_event_tx
|
||
.send(AppEvent::UpdateReasoningEffort(effort));
|
||
self.app_event_tx.send(AppEvent::PersistModelSelection {
|
||
model: model.clone(),
|
||
effort,
|
||
});
|
||
tracing::info!(
|
||
"Selected model: {}, Selected effort: {}",
|
||
model,
|
||
effort
|
||
.map(|e| e.to_string())
|
||
.unwrap_or_else(|| "default".to_string())
|
||
);
|
||
}
|
||
|
||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||
pub(crate) fn open_approvals_popup(&mut self) {
|
||
self.open_approval_mode_popup(true);
|
||
}
|
||
|
||
/// Open a popup to choose the permissions mode (approval policy + sandbox policy).
|
||
pub(crate) fn open_permissions_popup(&mut self) {
|
||
let include_read_only = cfg!(target_os = "windows");
|
||
self.open_approval_mode_popup(include_read_only);
|
||
}
|
||
|
||
fn open_approval_mode_popup(&mut self, include_read_only: bool) {
|
||
let current_approval = self.config.approval_policy.value();
|
||
let current_sandbox = self.config.sandbox_policy.get();
|
||
let mut items: Vec<SelectionItem> = Vec::new();
|
||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||
|
||
#[cfg(target_os = "windows")]
|
||
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
||
#[cfg(target_os = "windows")]
|
||
let windows_degraded_sandbox_enabled =
|
||
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
|
||
#[cfg(not(target_os = "windows"))]
|
||
let windows_degraded_sandbox_enabled = false;
|
||
|
||
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
&& windows_degraded_sandbox_enabled
|
||
&& presets.iter().any(|preset| preset.id == "auto");
|
||
|
||
for preset in presets.into_iter() {
|
||
if !include_read_only && preset.id == "read-only" {
|
||
continue;
|
||
}
|
||
let is_current =
|
||
Self::preset_matches_current(current_approval, current_sandbox, &preset);
|
||
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
|
||
"Default (non-elevated sandbox)".to_string()
|
||
} else {
|
||
preset.label.to_string()
|
||
};
|
||
let description = Some(preset.description.to_string());
|
||
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
|
||
Ok(()) => None,
|
||
Err(err) => Some(err.to_string()),
|
||
};
|
||
let requires_confirmation = preset.id == "full-access"
|
||
&& !self
|
||
.config
|
||
.notices
|
||
.hide_full_access_warning
|
||
.unwrap_or(false);
|
||
let actions: Vec<SelectionAction> = if requires_confirmation {
|
||
let preset_clone = preset.clone();
|
||
vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenFullAccessConfirmation {
|
||
preset: preset_clone.clone(),
|
||
return_to_permissions: !include_read_only,
|
||
});
|
||
})]
|
||
} else if preset.id == "auto" {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if WindowsSandboxLevel::from_config(&self.config)
|
||
== WindowsSandboxLevel::Disabled
|
||
{
|
||
let preset_clone = preset.clone();
|
||
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
|
||
self.config.codex_home.as_path(),
|
||
)
|
||
{
|
||
vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||
preset: preset_clone.clone(),
|
||
mode: WindowsSandboxEnableMode::Elevated,
|
||
});
|
||
})]
|
||
} else {
|
||
vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
|
||
preset: preset_clone.clone(),
|
||
});
|
||
})]
|
||
}
|
||
} else if let Some((sample_paths, extra_count, failed_scan)) =
|
||
self.world_writable_warning_details()
|
||
{
|
||
let preset_clone = preset.clone();
|
||
vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||
preset: Some(preset_clone.clone()),
|
||
sample_paths: sample_paths.clone(),
|
||
extra_count,
|
||
failed_scan,
|
||
});
|
||
})]
|
||
} else {
|
||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||
}
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||
}
|
||
} else {
|
||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||
};
|
||
items.push(SelectionItem {
|
||
name,
|
||
description,
|
||
is_current,
|
||
actions,
|
||
dismiss_on_select: true,
|
||
disabled_reason,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||
vec![
|
||
"The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(),
|
||
"/setup-elevated-sandbox".cyan(),
|
||
".".dim(),
|
||
]
|
||
.into()
|
||
});
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Update Model Permissions".to_string()),
|
||
footer_note,
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) fn open_experimental_popup(&mut self) {
|
||
let features: Vec<ExperimentalFeatureItem> = FEATURES
|
||
.iter()
|
||
.filter_map(|spec| {
|
||
let name = spec.stage.experimental_menu_name()?;
|
||
let description = spec.stage.experimental_menu_description()?;
|
||
Some(ExperimentalFeatureItem {
|
||
feature: spec.id,
|
||
name: name.to_string(),
|
||
description: description.to_string(),
|
||
enabled: self.config.features.enabled(spec.id),
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone());
|
||
self.bottom_pane.show_view(Box::new(view));
|
||
}
|
||
|
||
fn approval_preset_actions(
|
||
approval: AskForApproval,
|
||
sandbox: SandboxPolicy,
|
||
) -> Vec<SelectionAction> {
|
||
vec![Box::new(move |tx| {
|
||
let sandbox_clone = sandbox.clone();
|
||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: Some(approval),
|
||
sandbox_policy: Some(sandbox_clone.clone()),
|
||
windows_sandbox_level: None,
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
collaboration_mode: None,
|
||
personality: None,
|
||
}));
|
||
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
|
||
tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone));
|
||
})]
|
||
}
|
||
|
||
fn preset_matches_current(
|
||
current_approval: AskForApproval,
|
||
current_sandbox: &SandboxPolicy,
|
||
preset: &ApprovalPreset,
|
||
) -> bool {
|
||
if current_approval != preset.approval {
|
||
return false;
|
||
}
|
||
matches!(
|
||
(&preset.sandbox, current_sandbox),
|
||
(SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly)
|
||
| (
|
||
SandboxPolicy::DangerFullAccess,
|
||
SandboxPolicy::DangerFullAccess
|
||
)
|
||
| (
|
||
SandboxPolicy::WorkspaceWrite { .. },
|
||
SandboxPolicy::WorkspaceWrite { .. }
|
||
)
|
||
)
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
|
||
if self
|
||
.config
|
||
.notices
|
||
.hide_world_writable_warning
|
||
.unwrap_or(false)
|
||
{
|
||
return None;
|
||
}
|
||
let cwd = self.config.cwd.clone();
|
||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||
match codex_windows_sandbox::apply_world_writable_scan_and_denies(
|
||
self.config.codex_home.as_path(),
|
||
cwd.as_path(),
|
||
&env_map,
|
||
self.config.sandbox_policy.get(),
|
||
Some(self.config.codex_home.as_path()),
|
||
) {
|
||
Ok(_) => None,
|
||
Err(_) => Some((Vec::new(), 0, true)),
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
|
||
None
|
||
}
|
||
|
||
pub(crate) fn open_full_access_confirmation(
|
||
&mut self,
|
||
preset: ApprovalPreset,
|
||
return_to_permissions: bool,
|
||
) {
|
||
let approval = preset.approval;
|
||
let sandbox = preset.sandbox;
|
||
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
|
||
let title_line = Line::from("Enable full access?").bold();
|
||
let info_line = Line::from(vec![
|
||
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
|
||
.into(),
|
||
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
|
||
.fg(Color::Red),
|
||
]);
|
||
header_children.push(Box::new(title_line));
|
||
header_children.push(Box::new(
|
||
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
|
||
));
|
||
let header = ColumnRenderable::with(header_children);
|
||
|
||
let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone());
|
||
accept_actions.push(Box::new(|tx| {
|
||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||
}));
|
||
|
||
let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox);
|
||
accept_and_remember_actions.push(Box::new(|tx| {
|
||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
|
||
}));
|
||
|
||
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||
if return_to_permissions {
|
||
tx.send(AppEvent::OpenPermissionsPopup);
|
||
} else {
|
||
tx.send(AppEvent::OpenApprovalsPopup);
|
||
}
|
||
})];
|
||
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: "Yes, continue anyway".to_string(),
|
||
description: Some("Apply full access for this session".to_string()),
|
||
actions: accept_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Yes, and don't ask again".to_string(),
|
||
description: Some("Enable full access and remember this choice".to_string()),
|
||
actions: accept_and_remember_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Cancel".to_string(),
|
||
description: Some("Go back without enabling full access".to_string()),
|
||
actions: deny_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(header),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn open_world_writable_warning_confirmation(
|
||
&mut self,
|
||
preset: Option<ApprovalPreset>,
|
||
sample_paths: Vec<String>,
|
||
extra_count: usize,
|
||
failed_scan: bool,
|
||
) {
|
||
let (approval, sandbox) = match &preset {
|
||
Some(p) => (Some(p.approval), Some(p.sandbox.clone())),
|
||
None => (None, None),
|
||
};
|
||
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
|
||
let describe_policy = |policy: &SandboxPolicy| match policy {
|
||
SandboxPolicy::WorkspaceWrite { .. } => "Agent mode",
|
||
SandboxPolicy::ReadOnly => "Read-Only mode",
|
||
_ => "Agent mode",
|
||
};
|
||
let mode_label = preset
|
||
.as_ref()
|
||
.map(|p| describe_policy(&p.sandbox))
|
||
.unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get()));
|
||
let info_line = if failed_scan {
|
||
Line::from(vec![
|
||
"We couldn't complete the world-writable scan, so protections cannot be verified. "
|
||
.into(),
|
||
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
|
||
.fg(Color::Red),
|
||
])
|
||
} else {
|
||
Line::from(vec![
|
||
"The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(),
|
||
" Consider removing write access for Everyone from the following folders:".into(),
|
||
])
|
||
};
|
||
header_children.push(Box::new(
|
||
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
|
||
));
|
||
|
||
if !sample_paths.is_empty() {
|
||
// Show up to three examples and optionally an "and X more" line.
|
||
let mut lines: Vec<Line> = Vec::new();
|
||
lines.push(Line::from(""));
|
||
for p in &sample_paths {
|
||
lines.push(Line::from(format!(" - {p}")));
|
||
}
|
||
if extra_count > 0 {
|
||
lines.push(Line::from(format!("and {extra_count} more")));
|
||
}
|
||
header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||
}
|
||
let header = ColumnRenderable::with(header_children);
|
||
|
||
// Build actions ensuring acknowledgement happens before applying the new sandbox policy,
|
||
// so downstream policy-change hooks don't re-trigger the warning.
|
||
let mut accept_actions: Vec<SelectionAction> = Vec::new();
|
||
// Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals),
|
||
// to avoid duplicate warnings from the ensuing policy change.
|
||
if preset.is_some() {
|
||
accept_actions.push(Box::new(|tx| {
|
||
tx.send(AppEvent::SkipNextWorldWritableScan);
|
||
}));
|
||
}
|
||
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
|
||
accept_actions.extend(Self::approval_preset_actions(approval, sandbox));
|
||
}
|
||
|
||
let mut accept_and_remember_actions: Vec<SelectionAction> = Vec::new();
|
||
accept_and_remember_actions.push(Box::new(|tx| {
|
||
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
|
||
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
|
||
}));
|
||
if let (Some(approval), Some(sandbox)) = (approval, sandbox) {
|
||
accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox));
|
||
}
|
||
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: "Continue".to_string(),
|
||
description: Some(format!("Apply {mode_label} for this session")),
|
||
actions: accept_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Continue and don't warn again".to_string(),
|
||
description: Some(format!("Enable {mode_label} and remember this choice")),
|
||
actions: accept_and_remember_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(header),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub(crate) fn open_world_writable_warning_confirmation(
|
||
&mut self,
|
||
_preset: Option<ApprovalPreset>,
|
||
_sample_paths: Vec<String>,
|
||
_extra_count: usize,
|
||
_failed_scan: bool,
|
||
) {
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
|
||
use ratatui_macros::line;
|
||
|
||
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
|
||
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
|
||
// directly (no elevation prompts).
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(*Box::new(
|
||
Paragraph::new(vec![
|
||
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
|
||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||
])
|
||
.wrap(Wrap { trim: false }),
|
||
));
|
||
|
||
let preset_clone = preset;
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: "Enable experimental sandbox".to_string(),
|
||
description: None,
|
||
actions: vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||
preset: preset_clone.clone(),
|
||
mode: WindowsSandboxEnableMode::Legacy,
|
||
});
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Go back".to_string(),
|
||
description: None,
|
||
actions: vec![Box::new(|tx| {
|
||
tx.send(AppEvent::OpenApprovalsPopup);
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: None,
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(header),
|
||
..Default::default()
|
||
});
|
||
return;
|
||
}
|
||
|
||
let current_approval = self.config.approval_policy.value();
|
||
let current_sandbox = self.config.sandbox_policy.get();
|
||
let presets = builtin_approval_presets();
|
||
let stay_full_access = presets
|
||
.iter()
|
||
.find(|preset| preset.id == "full-access")
|
||
.is_some_and(|preset| {
|
||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||
});
|
||
self.otel_manager
|
||
.counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]);
|
||
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(*Box::new(
|
||
Paragraph::new(vec![
|
||
line!["Set Up Agent Sandbox".bold()],
|
||
line![""],
|
||
line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."],
|
||
line!["Learn more: https://developers.openai.com/codex/windows"],
|
||
])
|
||
.wrap(Wrap { trim: false }),
|
||
));
|
||
|
||
let stay_label = if stay_full_access {
|
||
"Stay in Agent Full Access".to_string()
|
||
} else {
|
||
"Stay in Read-Only".to_string()
|
||
};
|
||
let mut stay_actions = if stay_full_access {
|
||
Vec::new()
|
||
} else {
|
||
presets
|
||
.iter()
|
||
.find(|preset| preset.id == "read-only")
|
||
.map(|preset| {
|
||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||
})
|
||
.unwrap_or_default()
|
||
};
|
||
stay_actions.insert(
|
||
0,
|
||
Box::new({
|
||
let otel = self.otel_manager.clone();
|
||
move |_tx| {
|
||
otel.counter("codex.windows_sandbox.elevated_prompt_decline", 1, &[]);
|
||
}
|
||
}),
|
||
);
|
||
|
||
let accept_otel = self.otel_manager.clone();
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: "Set up agent sandbox (requires elevation)".to_string(),
|
||
description: None,
|
||
actions: vec![Box::new(move |tx| {
|
||
accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]);
|
||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||
preset: preset.clone(),
|
||
});
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: stay_label,
|
||
description: None,
|
||
actions: stay_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: None,
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(header),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||
&mut self,
|
||
preset: ApprovalPreset,
|
||
reason: WindowsSandboxFallbackReason,
|
||
) {
|
||
use ratatui_macros::line;
|
||
|
||
let _ = reason;
|
||
|
||
let current_approval = self.config.approval_policy.value();
|
||
let current_sandbox = self.config.sandbox_policy.get();
|
||
let presets = builtin_approval_presets();
|
||
let stay_full_access = presets
|
||
.iter()
|
||
.find(|preset| preset.id == "full-access")
|
||
.is_some_and(|preset| {
|
||
Self::preset_matches_current(current_approval, current_sandbox, preset)
|
||
});
|
||
let mut lines = Vec::new();
|
||
lines.push(line!["Use Non-Elevated Sandbox?".bold()]);
|
||
lines.push(line![""]);
|
||
lines.push(line![
|
||
"Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected."
|
||
]);
|
||
lines.push(line![
|
||
"Learn more: https://developers.openai.com/codex/windows"
|
||
]);
|
||
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||
|
||
let elevated_preset = preset.clone();
|
||
let legacy_preset = preset;
|
||
let stay_label = if stay_full_access {
|
||
"Stay in Agent Full Access".to_string()
|
||
} else {
|
||
"Stay in Read-Only".to_string()
|
||
};
|
||
let mut stay_actions = if stay_full_access {
|
||
Vec::new()
|
||
} else {
|
||
presets
|
||
.iter()
|
||
.find(|preset| preset.id == "read-only")
|
||
.map(|preset| {
|
||
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
|
||
})
|
||
.unwrap_or_default()
|
||
};
|
||
stay_actions.insert(
|
||
0,
|
||
Box::new({
|
||
let otel = self.otel_manager.clone();
|
||
move |_tx| {
|
||
otel.counter("codex.windows_sandbox.fallback_stay_current", 1, &[]);
|
||
}
|
||
}),
|
||
);
|
||
let items = vec![
|
||
SelectionItem {
|
||
name: "Try elevated agent sandbox setup again".to_string(),
|
||
description: None,
|
||
actions: vec![Box::new({
|
||
let otel = self.otel_manager.clone();
|
||
let preset = elevated_preset;
|
||
move |tx| {
|
||
otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]);
|
||
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
|
||
preset: preset.clone(),
|
||
});
|
||
}
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: "Use non-elevated agent sandbox".to_string(),
|
||
description: None,
|
||
actions: vec![Box::new({
|
||
let otel = self.otel_manager.clone();
|
||
let preset = legacy_preset;
|
||
move |tx| {
|
||
otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]);
|
||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||
preset: preset.clone(),
|
||
mode: WindowsSandboxEnableMode::Legacy,
|
||
});
|
||
}
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
SelectionItem {
|
||
name: stay_label,
|
||
description: None,
|
||
actions: stay_actions,
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
},
|
||
];
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: None,
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
header: Box::new(header),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub(crate) fn open_windows_sandbox_fallback_prompt(
|
||
&mut self,
|
||
_preset: ApprovalPreset,
|
||
_reason: WindowsSandboxFallbackReason,
|
||
) {
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
|
||
if self.config.forced_auto_mode_downgraded_on_windows
|
||
&& WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled
|
||
&& let Some(preset) = builtin_approval_presets()
|
||
.into_iter()
|
||
.find(|preset| preset.id == "auto")
|
||
{
|
||
self.open_windows_sandbox_enable_prompt(preset);
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||
// accidentally queue messages that will run under an unexpected mode.
|
||
self.bottom_pane.set_composer_input_enabled(
|
||
false,
|
||
Some("Input disabled until setup completes.".to_string()),
|
||
);
|
||
self.bottom_pane.ensure_status_indicator();
|
||
self.bottom_pane.set_interrupt_hint_visible(false);
|
||
self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string());
|
||
self.request_redraw();
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||
self.bottom_pane.set_composer_input_enabled(true, None);
|
||
self.bottom_pane.hide_status_indicator();
|
||
self.request_redraw();
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {
|
||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {}
|
||
|
||
/// Set the approval policy in the widget's config copy.
|
||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||
if let Err(err) = self.config.approval_policy.set(policy) {
|
||
tracing::warn!(%err, "failed to set approval_policy on chat config");
|
||
}
|
||
}
|
||
|
||
/// Set the sandbox policy in the widget's config copy.
|
||
pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> {
|
||
#[cfg(target_os = "windows")]
|
||
let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly)
|
||
|| WindowsSandboxLevel::from_config(&self.config) != WindowsSandboxLevel::Disabled;
|
||
|
||
self.config.sandbox_policy.set(policy)?;
|
||
|
||
#[cfg(target_os = "windows")]
|
||
if should_clear_downgrade {
|
||
self.config.forced_auto_mode_downgraded_on_windows = false;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
|
||
if enabled {
|
||
self.config.features.enable(feature);
|
||
} else {
|
||
self.config.features.disable(feature);
|
||
}
|
||
if feature == Feature::Steer {
|
||
self.bottom_pane.set_steer_enabled(enabled);
|
||
}
|
||
if feature == Feature::CollaborationModes {
|
||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||
let settings = self.current_collaboration_mode.settings.clone();
|
||
self.current_collaboration_mode = CollaborationMode {
|
||
mode: ModeKind::Custom,
|
||
settings,
|
||
};
|
||
self.active_collaboration_mask = None;
|
||
self.update_collaboration_mode_indicator();
|
||
self.refresh_model_display();
|
||
self.request_redraw();
|
||
}
|
||
if feature == Feature::Personality {
|
||
self.sync_personality_command_enabled();
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
if matches!(
|
||
feature,
|
||
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
|
||
) {
|
||
self.bottom_pane.set_windows_degraded_sandbox_active(
|
||
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||
&& matches!(
|
||
WindowsSandboxLevel::from_config(&self.config),
|
||
WindowsSandboxLevel::RestrictedToken
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
|
||
self.config.notices.hide_full_access_warning = Some(acknowledged);
|
||
}
|
||
|
||
pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) {
|
||
self.config.notices.hide_world_writable_warning = Some(acknowledged);
|
||
}
|
||
|
||
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
|
||
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
|
||
if hidden {
|
||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||
}
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||
pub(crate) fn world_writable_warning_hidden(&self) -> bool {
|
||
self.config
|
||
.notices
|
||
.hide_world_writable_warning
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Set the reasoning effort in the stored collaboration mode.
|
||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||
self.current_collaboration_mode =
|
||
self.current_collaboration_mode
|
||
.with_updates(None, Some(effort), None);
|
||
if self.collaboration_modes_enabled()
|
||
&& let Some(mask) = self.active_collaboration_mask.as_mut()
|
||
{
|
||
mask.reasoning_effort = Some(effort);
|
||
}
|
||
}
|
||
|
||
/// Set the personality in the widget's config copy.
|
||
pub(crate) fn set_personality(&mut self, personality: Personality) {
|
||
self.config.model_personality = Some(personality);
|
||
}
|
||
|
||
/// Set the model in the widget's config copy and stored collaboration mode.
|
||
pub(crate) fn set_model(&mut self, model: &str) {
|
||
self.current_collaboration_mode =
|
||
self.current_collaboration_mode
|
||
.with_updates(Some(model.to_string()), None, None);
|
||
if self.collaboration_modes_enabled()
|
||
&& let Some(mask) = self.active_collaboration_mask.as_mut()
|
||
{
|
||
mask.model = Some(model.to_string());
|
||
}
|
||
self.refresh_model_display();
|
||
}
|
||
|
||
pub(crate) fn current_model(&self) -> &str {
|
||
if !self.collaboration_modes_enabled() {
|
||
return self.current_collaboration_mode.model();
|
||
}
|
||
self.active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.model.as_deref())
|
||
.unwrap_or_else(|| self.current_collaboration_mode.model())
|
||
}
|
||
|
||
fn sync_personality_command_enabled(&mut self) {
|
||
self.bottom_pane
|
||
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
|
||
}
|
||
|
||
fn current_model_supports_personality(&self) -> bool {
|
||
let model = self.current_model();
|
||
self.models_manager
|
||
.try_list_models(&self.config)
|
||
.ok()
|
||
.and_then(|models| {
|
||
models
|
||
.into_iter()
|
||
.find(|preset| preset.model == model)
|
||
.map(|preset| preset.supports_personality)
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
#[allow(dead_code)] // Used in tests
|
||
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
|
||
&self.current_collaboration_mode
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn current_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||
self.effective_reasoning_effort()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind {
|
||
self.active_mode_kind()
|
||
}
|
||
|
||
fn is_session_configured(&self) -> bool {
|
||
self.thread_id.is_some()
|
||
}
|
||
|
||
fn collaboration_modes_enabled(&self) -> bool {
|
||
self.config.features.enabled(Feature::CollaborationModes)
|
||
}
|
||
|
||
fn initial_collaboration_mask(
|
||
config: &Config,
|
||
models_manager: &ModelsManager,
|
||
model_override: Option<&str>,
|
||
) -> Option<CollaborationModeMask> {
|
||
if !config.features.enabled(Feature::CollaborationModes) {
|
||
return None;
|
||
}
|
||
let mut mask = match config.experimental_mode {
|
||
Some(kind) => collaboration_modes::mask_for_kind(models_manager, kind)?,
|
||
None => collaboration_modes::default_mask(models_manager)?,
|
||
};
|
||
if let Some(model_override) = model_override {
|
||
mask.model = Some(model_override.to_string());
|
||
}
|
||
Some(mask)
|
||
}
|
||
|
||
fn active_mode_kind(&self) -> ModeKind {
|
||
self.active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.mode)
|
||
.unwrap_or(ModeKind::Custom)
|
||
}
|
||
|
||
fn effective_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||
if !self.collaboration_modes_enabled() {
|
||
return self.current_collaboration_mode.reasoning_effort();
|
||
}
|
||
let current_effort = self.current_collaboration_mode.reasoning_effort();
|
||
self.active_collaboration_mask
|
||
.as_ref()
|
||
.and_then(|mask| mask.reasoning_effort)
|
||
.unwrap_or(current_effort)
|
||
}
|
||
|
||
fn effective_collaboration_mode(&self) -> CollaborationMode {
|
||
if !self.collaboration_modes_enabled() {
|
||
return self.current_collaboration_mode.clone();
|
||
}
|
||
self.active_collaboration_mask.as_ref().map_or_else(
|
||
|| self.current_collaboration_mode.clone(),
|
||
|mask| self.current_collaboration_mode.apply_mask(mask),
|
||
)
|
||
}
|
||
|
||
fn refresh_model_display(&mut self) {
|
||
let effective = self.effective_collaboration_mode();
|
||
self.session_header.set_model(effective.model());
|
||
}
|
||
|
||
fn model_display_name(&self) -> &str {
|
||
let model = self.current_model();
|
||
if model.is_empty() {
|
||
DEFAULT_MODEL_DISPLAY_NAME
|
||
} else {
|
||
model
|
||
}
|
||
}
|
||
|
||
/// Get the label for the current collaboration mode.
|
||
fn collaboration_mode_label(&self) -> Option<&'static str> {
|
||
if !self.collaboration_modes_enabled() {
|
||
return None;
|
||
}
|
||
match self.active_mode_kind() {
|
||
ModeKind::Plan => Some("Plan"),
|
||
ModeKind::Code => Some("Code"),
|
||
ModeKind::PairProgramming => Some("Pair Programming"),
|
||
ModeKind::Execute => Some("Execute"),
|
||
ModeKind::Custom => None,
|
||
}
|
||
}
|
||
|
||
fn collaboration_mode_indicator(&self) -> Option<CollaborationModeIndicator> {
|
||
if !self.collaboration_modes_enabled() {
|
||
return None;
|
||
}
|
||
match self.active_mode_kind() {
|
||
ModeKind::Plan => Some(CollaborationModeIndicator::Plan),
|
||
ModeKind::Code => Some(CollaborationModeIndicator::Code),
|
||
ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming),
|
||
ModeKind::Execute => Some(CollaborationModeIndicator::Execute),
|
||
ModeKind::Custom => None,
|
||
}
|
||
}
|
||
|
||
fn update_collaboration_mode_indicator(&mut self) {
|
||
let indicator = self.collaboration_mode_indicator();
|
||
self.bottom_pane.set_collaboration_mode_indicator(indicator);
|
||
}
|
||
|
||
fn personality_label(personality: Personality) -> &'static str {
|
||
match personality {
|
||
Personality::Friendly => "Friendly",
|
||
Personality::Pragmatic => "Pragmatic",
|
||
}
|
||
}
|
||
|
||
fn personality_description(personality: Personality) -> &'static str {
|
||
match personality {
|
||
Personality::Friendly => "Warm, collaborative, and helpful.",
|
||
Personality::Pragmatic => "Concise, task-focused, and direct.",
|
||
}
|
||
}
|
||
|
||
/// Cycle to the next collaboration mode variant (Plan -> Code -> Plan).
|
||
fn cycle_collaboration_mode(&mut self) {
|
||
if !self.collaboration_modes_enabled() {
|
||
return;
|
||
}
|
||
|
||
if let Some(next_mask) = collaboration_modes::next_mask(
|
||
self.models_manager.as_ref(),
|
||
self.active_collaboration_mask.as_ref(),
|
||
) {
|
||
self.set_collaboration_mask(next_mask);
|
||
}
|
||
}
|
||
|
||
/// Update the active collaboration mask.
|
||
///
|
||
/// When collaboration modes are enabled and a preset is selected (not Custom),
|
||
/// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||
pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) {
|
||
if !self.collaboration_modes_enabled() {
|
||
return;
|
||
}
|
||
self.active_collaboration_mask = Some(mask);
|
||
self.update_collaboration_mode_indicator();
|
||
self.refresh_model_display();
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn connectors_enabled(&self) -> bool {
|
||
self.config.features.enabled(Feature::Apps)
|
||
}
|
||
|
||
fn connectors_for_mentions(&self) -> Option<&[connectors::AppInfo]> {
|
||
if !self.connectors_enabled() {
|
||
return None;
|
||
}
|
||
|
||
match &self.connectors_cache {
|
||
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Build a placeholder header cell while the session is configuring.
|
||
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
|
||
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
|
||
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
|
||
placeholder_style,
|
||
None,
|
||
config.cwd.clone(),
|
||
CODEX_CLI_VERSION,
|
||
))
|
||
}
|
||
|
||
/// Merge the real session info cell with any placeholder header to avoid double boxes.
|
||
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
|
||
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
|
||
let merged_header = if let Some(active) = self.active_cell.take() {
|
||
if active
|
||
.as_any()
|
||
.is::<history_cell::SessionHeaderHistoryCell>()
|
||
{
|
||
// Reuse the existing placeholder header to avoid rendering two boxes.
|
||
if let Some(cell) = session_info_cell.take() {
|
||
self.active_cell = Some(cell);
|
||
}
|
||
true
|
||
} else {
|
||
self.active_cell = Some(active);
|
||
false
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
self.flush_active_cell();
|
||
|
||
if !merged_header && let Some(cell) = session_info_cell {
|
||
self.add_boxed_history(cell);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
|
||
self.add_to_history(history_cell::new_info_event(message, hint));
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn add_plain_history_lines(&mut self, lines: Vec<Line<'static>>) {
|
||
self.add_boxed_history(Box::new(PlainHistoryCell::new(lines)));
|
||
self.request_redraw();
|
||
}
|
||
|
||
pub(crate) fn add_error_message(&mut self, message: String) {
|
||
self.add_to_history(history_cell::new_error_event(message));
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn rename_confirmation_cell(name: &str, thread_id: Option<ThreadId>) -> PlainHistoryCell {
|
||
let resume_cmd = codex_core::util::resume_command(Some(name), thread_id)
|
||
.unwrap_or_else(|| format!("codex resume {name}"));
|
||
let name = name.to_string();
|
||
let line = vec![
|
||
"• ".into(),
|
||
"Thread renamed to ".into(),
|
||
name.cyan(),
|
||
", to resume this thread run ".into(),
|
||
resume_cmd.cyan(),
|
||
];
|
||
PlainHistoryCell::new(vec![line.into()])
|
||
}
|
||
|
||
pub(crate) fn add_mcp_output(&mut self) {
|
||
if self.config.mcp_servers.is_empty() {
|
||
self.add_to_history(history_cell::empty_mcp_output());
|
||
} else {
|
||
self.submit_op(Op::ListMcpTools);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn add_connectors_output(&mut self) {
|
||
if !self.connectors_enabled() {
|
||
self.add_info_message(
|
||
"Apps are disabled.".to_string(),
|
||
Some("Enable the apps feature to use $ or /apps.".to_string()),
|
||
);
|
||
return;
|
||
}
|
||
|
||
match self.connectors_cache.clone() {
|
||
ConnectorsCacheState::Ready(snapshot) => {
|
||
if snapshot.connectors.is_empty() {
|
||
self.add_info_message("No apps available.".to_string(), None);
|
||
} else {
|
||
self.open_connectors_popup(&snapshot.connectors);
|
||
}
|
||
}
|
||
ConnectorsCacheState::Failed(err) => {
|
||
self.add_to_history(history_cell::new_error_event(err));
|
||
// Retry on demand so `/apps` can recover after transient failures.
|
||
self.prefetch_connectors();
|
||
}
|
||
ConnectorsCacheState::Loading => {
|
||
self.add_to_history(history_cell::new_info_event(
|
||
"Apps are still loading.".to_string(),
|
||
Some("Try again in a moment.".to_string()),
|
||
));
|
||
}
|
||
ConnectorsCacheState::Uninitialized => {
|
||
self.prefetch_connectors();
|
||
self.add_to_history(history_cell::new_info_event(
|
||
"Apps are still loading.".to_string(),
|
||
Some("Try again in a moment.".to_string()),
|
||
));
|
||
}
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
|
||
fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) {
|
||
let total = connectors.len();
|
||
let installed = connectors
|
||
.iter()
|
||
.filter(|connector| connector.is_accessible)
|
||
.count();
|
||
let mut header = ColumnRenderable::new();
|
||
header.push(Line::from("Apps".bold()));
|
||
header.push(Line::from(
|
||
"Use $ to insert an installed app into your prompt.".dim(),
|
||
));
|
||
header.push(Line::from(
|
||
format!("Installed {installed} of {total} available apps.").dim(),
|
||
));
|
||
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
|
||
for connector in connectors {
|
||
let connector_label = connectors::connector_display_label(connector);
|
||
let connector_title = connector_label.clone();
|
||
let link_description = Self::connector_description(connector);
|
||
let description = Self::connector_brief_description(connector);
|
||
let search_value = format!("{connector_label} {}", connector.id);
|
||
let mut item = SelectionItem {
|
||
name: connector_label,
|
||
description: Some(description),
|
||
search_value: Some(search_value),
|
||
..Default::default()
|
||
};
|
||
let is_installed = connector.is_accessible;
|
||
let (selected_label, missing_label, instructions) = if connector.is_accessible {
|
||
(
|
||
"Press Enter to view the app link.",
|
||
"App link unavailable.",
|
||
"Manage this app in your browser.",
|
||
)
|
||
} else {
|
||
(
|
||
"Press Enter to view the install link.",
|
||
"Install link unavailable.",
|
||
"Install this app in your browser, then reload Codex.",
|
||
)
|
||
};
|
||
if let Some(install_url) = connector.install_url.clone() {
|
||
let title = connector_title.clone();
|
||
let instructions = instructions.to_string();
|
||
let description = link_description.clone();
|
||
item.actions = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenAppLink {
|
||
title: title.clone(),
|
||
description: description.clone(),
|
||
instructions: instructions.clone(),
|
||
url: install_url.clone(),
|
||
is_installed,
|
||
});
|
||
})];
|
||
item.dismiss_on_select = true;
|
||
item.selected_description = Some(selected_label.to_string());
|
||
} else {
|
||
item.actions = vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||
history_cell::new_info_event(missing_label.to_string(), None),
|
||
)));
|
||
})];
|
||
item.dismiss_on_select = true;
|
||
item.selected_description = Some(missing_label.to_string());
|
||
}
|
||
items.push(item);
|
||
}
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
header: Box::new(header),
|
||
footer_hint: Some(Self::connectors_popup_hint_line()),
|
||
items,
|
||
is_searchable: true,
|
||
search_placeholder: Some("Type to search apps".to_string()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn connectors_popup_hint_line() -> Line<'static> {
|
||
Line::from(vec![
|
||
"Press ".into(),
|
||
key_hint::plain(KeyCode::Esc).into(),
|
||
" to close.".into(),
|
||
])
|
||
}
|
||
|
||
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
|
||
let status_label = if connector.is_accessible {
|
||
"Connected"
|
||
} else {
|
||
"Can be installed"
|
||
};
|
||
match Self::connector_description(connector) {
|
||
Some(description) => format!("{status_label} · {description}"),
|
||
None => status_label.to_string(),
|
||
}
|
||
}
|
||
|
||
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
|
||
connector
|
||
.description
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|description| !description.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// Forward file-search results to the bottom pane.
|
||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||
self.bottom_pane.on_file_search_result(query, matches);
|
||
}
|
||
|
||
/// Handles a Ctrl+C press at the chat-widget layer.
|
||
///
|
||
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
|
||
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
||
/// is armed.
|
||
///
|
||
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
|
||
/// quit.
|
||
fn on_ctrl_c(&mut self) {
|
||
let key = key_hint::ctrl(KeyCode::Char('c'));
|
||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||
if modal_or_popup_active {
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
self.bottom_pane.clear_quit_shortcut_hint();
|
||
} else {
|
||
self.arm_quit_shortcut(key);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||
if self.is_cancellable_work_active() {
|
||
self.submit_op(Op::Interrupt);
|
||
} else {
|
||
self.request_quit_without_confirmation();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if self.quit_shortcut_active_for(key) {
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
self.request_quit_without_confirmation();
|
||
return;
|
||
}
|
||
|
||
self.arm_quit_shortcut(key);
|
||
|
||
if self.is_cancellable_work_active() {
|
||
self.submit_op(Op::Interrupt);
|
||
}
|
||
}
|
||
|
||
/// Handles a Ctrl+D press at the chat-widget layer.
|
||
///
|
||
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
||
/// Otherwise it should be routed to the active view and not attempt to quit.
|
||
fn on_ctrl_d(&mut self) -> bool {
|
||
let key = key_hint::ctrl(KeyCode::Char('d'));
|
||
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
|
||
{
|
||
return false;
|
||
}
|
||
|
||
self.request_quit_without_confirmation();
|
||
return true;
|
||
}
|
||
|
||
if self.quit_shortcut_active_for(key) {
|
||
self.quit_shortcut_expires_at = None;
|
||
self.quit_shortcut_key = None;
|
||
self.request_quit_without_confirmation();
|
||
return true;
|
||
}
|
||
|
||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
|
||
return false;
|
||
}
|
||
|
||
self.arm_quit_shortcut(key);
|
||
true
|
||
}
|
||
|
||
/// True if `key` matches the armed quit shortcut and the window has not expired.
|
||
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
|
||
self.quit_shortcut_key == Some(key)
|
||
&& self
|
||
.quit_shortcut_expires_at
|
||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||
}
|
||
|
||
/// Arm the double-press quit shortcut and show the footer hint.
|
||
///
|
||
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
|
||
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
|
||
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
|
||
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
|
||
self.quit_shortcut_expires_at = Instant::now()
|
||
.checked_add(QUIT_SHORTCUT_TIMEOUT)
|
||
.or_else(|| Some(Instant::now()));
|
||
self.quit_shortcut_key = Some(key);
|
||
self.bottom_pane.show_quit_shortcut_hint(key);
|
||
}
|
||
|
||
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
|
||
fn is_cancellable_work_active(&self) -> bool {
|
||
self.bottom_pane.is_task_running() || self.is_review_mode
|
||
}
|
||
|
||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||
self.bottom_pane.composer_is_empty()
|
||
}
|
||
|
||
pub(crate) fn submit_user_message_with_mode(
|
||
&mut self,
|
||
text: String,
|
||
collaboration_mode: CollaborationModeMask,
|
||
) {
|
||
self.set_collaboration_mask(collaboration_mode);
|
||
self.submit_user_message(text.into());
|
||
}
|
||
|
||
/// True when the UI is in the regular composer state with no running task,
|
||
/// no modal overlay (e.g. approvals or status indicator), and no composer popups.
|
||
/// In this state Esc-Esc backtracking is enabled.
|
||
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
|
||
self.bottom_pane.is_normal_backtrack_mode()
|
||
}
|
||
|
||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||
self.bottom_pane.insert_str(text);
|
||
}
|
||
|
||
/// Replace the composer content with the provided text and reset cursor.
|
||
pub(crate) fn set_composer_text(
|
||
&mut self,
|
||
text: String,
|
||
text_elements: Vec<TextElement>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
) {
|
||
self.bottom_pane
|
||
.set_composer_text(text, text_elements, local_image_paths);
|
||
}
|
||
|
||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||
self.bottom_pane.show_esc_backtrack_hint();
|
||
}
|
||
|
||
pub(crate) fn clear_esc_backtrack_hint(&mut self) {
|
||
self.bottom_pane.clear_esc_backtrack_hint();
|
||
}
|
||
/// Forward an `Op` directly to codex.
|
||
pub(crate) fn submit_op(&mut self, op: Op) {
|
||
// Record outbound operation for session replay fidelity.
|
||
crate::session_log::log_outbound_op(&op);
|
||
if matches!(&op, Op::Review { .. }) && !self.bottom_pane.is_task_running() {
|
||
self.bottom_pane.set_task_running(true);
|
||
}
|
||
if let Err(e) = self.codex_op_tx.send(op) {
|
||
tracing::error!("failed to submit op: {e}");
|
||
}
|
||
}
|
||
|
||
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
|
||
self.add_to_history(history_cell::new_mcp_tools_output(
|
||
&self.config,
|
||
ev.tools,
|
||
ev.resources,
|
||
ev.resource_templates,
|
||
&ev.auth_statuses,
|
||
));
|
||
}
|
||
|
||
fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) {
|
||
let len = ev.custom_prompts.len();
|
||
debug!("received {len} custom prompts");
|
||
// Forward to bottom pane so the slash popup can show them now.
|
||
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
|
||
}
|
||
|
||
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
|
||
self.set_skills_from_response(&ev);
|
||
}
|
||
|
||
pub(crate) fn on_connectors_loaded(&mut self, result: Result<ConnectorsSnapshot, String>) {
|
||
self.connectors_cache = match result {
|
||
Ok(connectors) => ConnectorsCacheState::Ready(connectors),
|
||
Err(err) => ConnectorsCacheState::Failed(err),
|
||
};
|
||
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache {
|
||
self.bottom_pane
|
||
.set_connectors_snapshot(Some(snapshot.clone()));
|
||
} else {
|
||
self.bottom_pane.set_connectors_snapshot(None);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn open_review_popup(&mut self) {
|
||
let mut items: Vec<SelectionItem> = Vec::new();
|
||
|
||
items.push(SelectionItem {
|
||
name: "Review against a base branch".to_string(),
|
||
description: Some("(PR Style)".into()),
|
||
actions: vec![Box::new({
|
||
let cwd = self.config.cwd.clone();
|
||
move |tx| {
|
||
tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone()));
|
||
}
|
||
})],
|
||
dismiss_on_select: false,
|
||
..Default::default()
|
||
});
|
||
|
||
items.push(SelectionItem {
|
||
name: "Review uncommitted changes".to_string(),
|
||
actions: vec![Box::new(move |tx: &AppEventSender| {
|
||
tx.send(AppEvent::CodexOp(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::UncommittedChanges,
|
||
user_facing_hint: None,
|
||
},
|
||
}));
|
||
})],
|
||
dismiss_on_select: true,
|
||
..Default::default()
|
||
});
|
||
|
||
// New: Review a specific commit (opens commit picker)
|
||
items.push(SelectionItem {
|
||
name: "Review a commit".to_string(),
|
||
actions: vec![Box::new({
|
||
let cwd = self.config.cwd.clone();
|
||
move |tx| {
|
||
tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone()));
|
||
}
|
||
})],
|
||
dismiss_on_select: false,
|
||
..Default::default()
|
||
});
|
||
|
||
items.push(SelectionItem {
|
||
name: "Custom review instructions".to_string(),
|
||
actions: vec![Box::new(move |tx| {
|
||
tx.send(AppEvent::OpenReviewCustomPrompt);
|
||
})],
|
||
dismiss_on_select: false,
|
||
..Default::default()
|
||
});
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Select a review preset".into()),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
|
||
let branches = local_git_branches(cwd).await;
|
||
let current_branch = current_branch_name(cwd)
|
||
.await
|
||
.unwrap_or_else(|| "(detached HEAD)".to_string());
|
||
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
|
||
|
||
for option in branches {
|
||
let branch = option.clone();
|
||
items.push(SelectionItem {
|
||
name: format!("{current_branch} -> {branch}"),
|
||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::BaseBranch {
|
||
branch: branch.clone(),
|
||
},
|
||
user_facing_hint: None,
|
||
},
|
||
}));
|
||
})],
|
||
dismiss_on_select: true,
|
||
search_value: Some(option),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Select a base branch".to_string()),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
is_searchable: true,
|
||
search_placeholder: Some("Type to search branches".to_string()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
|
||
let commits = codex_core::git_info::recent_commits(cwd, 100).await;
|
||
|
||
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
|
||
for entry in commits {
|
||
let subject = entry.subject.clone();
|
||
let sha = entry.sha.clone();
|
||
let search_val = format!("{subject} {sha}");
|
||
|
||
items.push(SelectionItem {
|
||
name: subject.clone(),
|
||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::Commit {
|
||
sha: sha.clone(),
|
||
title: Some(subject.clone()),
|
||
},
|
||
user_facing_hint: None,
|
||
},
|
||
}));
|
||
})],
|
||
dismiss_on_select: true,
|
||
search_value: Some(search_val),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Select a commit to review".to_string()),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
is_searchable: true,
|
||
search_placeholder: Some("Type to search commits".to_string()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
pub(crate) fn show_review_custom_prompt(&mut self) {
|
||
let tx = self.app_event_tx.clone();
|
||
let view = CustomPromptView::new(
|
||
"Custom review instructions".to_string(),
|
||
"Type instructions and press Enter".to_string(),
|
||
None,
|
||
Box::new(move |prompt: String| {
|
||
let trimmed = prompt.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
return;
|
||
}
|
||
tx.send(AppEvent::CodexOp(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::Custom {
|
||
instructions: trimmed,
|
||
},
|
||
user_facing_hint: None,
|
||
},
|
||
}));
|
||
}),
|
||
);
|
||
self.bottom_pane.show_view(Box::new(view));
|
||
}
|
||
|
||
pub(crate) fn token_usage(&self) -> TokenUsage {
|
||
self.token_info
|
||
.as_ref()
|
||
.map(|ti| ti.total_token_usage.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub(crate) fn thread_id(&self) -> Option<ThreadId> {
|
||
self.thread_id
|
||
}
|
||
|
||
pub(crate) fn thread_name(&self) -> Option<String> {
|
||
self.thread_name.clone()
|
||
}
|
||
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
|
||
self.current_rollout_path.clone()
|
||
}
|
||
|
||
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
|
||
///
|
||
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
|
||
/// current active cell, and the overlay caches that tail; this key is what it uses to decide
|
||
/// whether it must recompute. When there is no active cell, this returns `None` so the overlay
|
||
/// can drop the tail entirely.
|
||
///
|
||
/// If callers mutate the active cell's transcript output without bumping the revision (or
|
||
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
|
||
/// the main viewport updates.
|
||
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
|
||
let cell = self.active_cell.as_ref()?;
|
||
Some(ActiveCellTranscriptKey {
|
||
revision: self.active_cell_revision,
|
||
is_stream_continuation: cell.is_stream_continuation(),
|
||
animation_tick: cell.transcript_animation_tick(),
|
||
})
|
||
}
|
||
|
||
/// Returns the active cell's transcript lines for a given terminal width.
|
||
///
|
||
/// This is a convenience for the transcript overlay live-tail path, and it intentionally
|
||
/// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers
|
||
/// should pass the same width the overlay uses; using a different width will cause wrapping
|
||
/// mismatches between the main viewport and the transcript overlay.
|
||
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
|
||
let cell = self.active_cell.as_ref()?;
|
||
let lines = cell.transcript_lines(width);
|
||
(!lines.is_empty()).then_some(lines)
|
||
}
|
||
|
||
/// Return a reference to the widget's current config (includes any
|
||
/// runtime overrides applied via TUI, e.g., model or approval policy).
|
||
pub(crate) fn config_ref(&self) -> &Config {
|
||
&self.config
|
||
}
|
||
|
||
pub(crate) fn clear_token_usage(&mut self) {
|
||
self.token_info = None;
|
||
}
|
||
|
||
fn as_renderable(&self) -> RenderableItem<'_> {
|
||
let active_cell_renderable = match &self.active_cell {
|
||
Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)),
|
||
None => RenderableItem::Owned(Box::new(())),
|
||
};
|
||
let mut flex = FlexRenderable::new();
|
||
flex.push(1, active_cell_renderable);
|
||
flex.push(
|
||
0,
|
||
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)),
|
||
);
|
||
RenderableItem::Owned(Box::new(flex))
|
||
}
|
||
}
|
||
|
||
impl Drop for ChatWidget {
|
||
fn drop(&mut self) {
|
||
self.stop_rate_limit_poller();
|
||
}
|
||
}
|
||
|
||
impl Renderable for ChatWidget {
|
||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||
self.as_renderable().render(area, buf);
|
||
self.last_rendered_width.set(Some(area.width as usize));
|
||
}
|
||
|
||
fn desired_height(&self, width: u16) -> u16 {
|
||
self.as_renderable().desired_height(width)
|
||
}
|
||
|
||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
self.as_renderable().cursor_pos(area)
|
||
}
|
||
}
|
||
|
||
enum Notification {
|
||
AgentTurnComplete { response: String },
|
||
ExecApprovalRequested { command: String },
|
||
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
|
||
ElicitationRequested { server_name: String },
|
||
}
|
||
|
||
impl Notification {
|
||
fn display(&self) -> String {
|
||
match self {
|
||
Notification::AgentTurnComplete { response } => {
|
||
Notification::agent_turn_preview(response)
|
||
.unwrap_or_else(|| "Agent turn complete".to_string())
|
||
}
|
||
Notification::ExecApprovalRequested { command } => {
|
||
format!("Approval requested: {}", truncate_text(command, 30))
|
||
}
|
||
Notification::EditApprovalRequested { cwd, changes } => {
|
||
format!(
|
||
"Codex wants to edit {}",
|
||
if changes.len() == 1 {
|
||
#[allow(clippy::unwrap_used)]
|
||
display_path_for(changes.first().unwrap(), cwd)
|
||
} else {
|
||
format!("{} files", changes.len())
|
||
}
|
||
)
|
||
}
|
||
Notification::ElicitationRequested { server_name } => {
|
||
format!("Approval requested by {server_name}")
|
||
}
|
||
}
|
||
}
|
||
|
||
fn type_name(&self) -> &str {
|
||
match self {
|
||
Notification::AgentTurnComplete { .. } => "agent-turn-complete",
|
||
Notification::ExecApprovalRequested { .. }
|
||
| Notification::EditApprovalRequested { .. }
|
||
| Notification::ElicitationRequested { .. } => "approval-requested",
|
||
}
|
||
}
|
||
|
||
fn allowed_for(&self, settings: &Notifications) -> bool {
|
||
match settings {
|
||
Notifications::Enabled(enabled) => *enabled,
|
||
Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()),
|
||
}
|
||
}
|
||
|
||
fn agent_turn_preview(response: &str) -> Option<String> {
|
||
let mut normalized = String::new();
|
||
for part in response.split_whitespace() {
|
||
if !normalized.is_empty() {
|
||
normalized.push(' ');
|
||
}
|
||
normalized.push_str(part);
|
||
}
|
||
let trimmed = normalized.trim();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES))
|
||
}
|
||
}
|
||
}
|
||
|
||
const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200;
|
||
|
||
const PLACEHOLDERS: [&str; 8] = [
|
||
"Explain this codebase",
|
||
"Summarize recent commits",
|
||
"Implement {feature}",
|
||
"Find and fix a bug in @filename",
|
||
"Write tests for @filename",
|
||
"Improve documentation in @filename",
|
||
"Run /review on my current changes",
|
||
"Use /skills to list available skills",
|
||
];
|
||
|
||
// Extract the first bold (Markdown) element in the form **...** from `s`.
|
||
// Returns the inner text if found; otherwise `None`.
|
||
fn extract_first_bold(s: &str) -> Option<String> {
|
||
let bytes = s.as_bytes();
|
||
let mut i = 0usize;
|
||
while i + 1 < bytes.len() {
|
||
if bytes[i] == b'*' && bytes[i + 1] == b'*' {
|
||
let start = i + 2;
|
||
let mut j = start;
|
||
while j + 1 < bytes.len() {
|
||
if bytes[j] == b'*' && bytes[j + 1] == b'*' {
|
||
// Found closing **
|
||
let inner = &s[start..j];
|
||
let trimmed = inner.trim();
|
||
if !trimmed.is_empty() {
|
||
return Some(trimmed.to_string());
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
j += 1;
|
||
}
|
||
// No closing; stop searching (wait for more deltas)
|
||
return None;
|
||
}
|
||
i += 1;
|
||
}
|
||
None
|
||
}
|
||
|
||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option<RateLimitSnapshot> {
|
||
match BackendClient::from_auth(base_url, &auth) {
|
||
Ok(client) => match client.get_rate_limits().await {
|
||
Ok(snapshot) => Some(snapshot),
|
||
Err(err) => {
|
||
debug!(error = ?err, "failed to fetch rate limits from /usage");
|
||
None
|
||
}
|
||
},
|
||
Err(err) => {
|
||
debug!(error = ?err, "failed to construct backend client for rate limits");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn show_review_commit_picker_with_entries(
|
||
chat: &mut ChatWidget,
|
||
entries: Vec<codex_core::git_info::CommitLogEntry>,
|
||
) {
|
||
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
|
||
for entry in entries {
|
||
let subject = entry.subject.clone();
|
||
let sha = entry.sha.clone();
|
||
let search_val = format!("{subject} {sha}");
|
||
|
||
items.push(SelectionItem {
|
||
name: subject.clone(),
|
||
actions: vec![Box::new(move |tx3: &AppEventSender| {
|
||
tx3.send(AppEvent::CodexOp(Op::Review {
|
||
review_request: ReviewRequest {
|
||
target: ReviewTarget::Commit {
|
||
sha: sha.clone(),
|
||
title: Some(subject.clone()),
|
||
},
|
||
user_facing_hint: None,
|
||
},
|
||
}));
|
||
})],
|
||
dismiss_on_select: true,
|
||
search_value: Some(search_val),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
chat.bottom_pane.show_selection_view(SelectionViewParams {
|
||
title: Some("Select a commit to review".to_string()),
|
||
footer_hint: Some(standard_popup_hint_line()),
|
||
items,
|
||
is_searchable: true,
|
||
search_placeholder: Some("Type to search commits".to_string()),
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
fn format_duration_short(seconds: u64) -> String {
|
||
if seconds < 60 {
|
||
"less than a minute".to_string()
|
||
} else if seconds < 3600 {
|
||
format!("{}m", seconds / 60)
|
||
} else if seconds < 86_400 {
|
||
format!("{}h", seconds / 3600)
|
||
} else {
|
||
format!("{}d", seconds / 86_400)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) mod tests;
|