diff --git a/codex-rs/tui/src/approval_events.rs b/codex-rs/tui/src/approval_events.rs new file mode 100644 index 0000000000..57e0959d56 --- /dev/null +++ b/codex-rs/tui/src/approval_events.rs @@ -0,0 +1,119 @@ +//! TUI-owned approval request models used while rendering and queueing prompts. +//! +//! These structs normalize app-server request params into the shape the TUI +//! needs while an approval may be deferred behind streaming output. Exec +//! approvals keep app-server decision and permission types; patch approvals add +//! the file-change display model collected from nearby thread items. + +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::diff_model::FileChange; +use codex_app_server_protocol::AdditionalPermissionProfile; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::ExecPolicyAmendment; +use codex_app_server_protocol::NetworkApprovalContext; +use codex_app_server_protocol::NetworkPolicyAmendment; +use codex_app_server_protocol::NetworkPolicyRuleAction; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ExecApprovalRequestEvent { + pub(crate) call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) approval_id: Option, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) proposed_execpolicy_amendment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) proposed_network_policy_amendments: Option>, + #[serde(default)] + pub(crate) available_decisions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) network_approval_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) additional_permissions: Option, +} + +impl ExecApprovalRequestEvent { + pub(crate) fn effective_approval_id(&self) -> String { + self.approval_id + .clone() + .unwrap_or_else(|| self.call_id.clone()) + } + + pub(crate) fn effective_available_decisions(&self) -> Vec { + match &self.available_decisions { + Some(decisions) => decisions.clone(), + None => Self::default_available_decisions( + self.network_approval_context.as_ref(), + self.proposed_execpolicy_amendment.as_ref(), + self.proposed_network_policy_amendments.as_deref(), + self.additional_permissions.as_ref(), + ), + } + } + + pub(crate) fn default_available_decisions( + network_approval_context: Option<&NetworkApprovalContext>, + proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>, + proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>, + additional_permissions: Option<&AdditionalPermissionProfile>, + ) -> Vec { + if network_approval_context.is_some() { + let mut decisions = vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::AcceptForSession, + ]; + if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| { + amendments + .iter() + .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) + }) { + decisions.push( + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment: amendment.clone(), + }, + ); + } + decisions.push(CommandExecutionApprovalDecision::Cancel); + return decisions; + } + + if additional_permissions.is_some() { + return vec![ + CommandExecutionApprovalDecision::Accept, + CommandExecutionApprovalDecision::Cancel, + ]; + } + + let mut decisions = vec![CommandExecutionApprovalDecision::Accept]; + if let Some(prefix) = proposed_execpolicy_amendment { + decisions.push( + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment: prefix.clone(), + }, + ); + } + decisions.push(CommandExecutionApprovalDecision::Cancel); + decisions + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ApplyPatchApprovalRequestEvent { + pub(crate) call_id: String, + #[serde(default)] + pub(crate) turn_id: String, + pub(crate) changes: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grant_root: Option, +} diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs new file mode 100644 index 0000000000..a49a4da3b6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -0,0 +1,107 @@ +//! User-message display models and helpers for the chat widget. +//! +//! The app-server preserves user input as structured chunks, while chat history +//! renders a single prompt row. This module owns that display projection and +//! the small compare key used to suppress duplicate rows for pending steers. + +use std::path::PathBuf; + +use codex_app_server_protocol::UserInput; +use codex_protocol::user_input::TextElement; + +use super::ChatWidget; +use super::append_text_with_rebased_elements; + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct UserMessageDisplay { + pub(super) message: String, + pub(super) remote_image_urls: Vec, + pub(super) local_images: Vec, + pub(super) text_elements: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PendingSteerCompareKey { + pub(super) message: String, + pub(super) image_count: usize, +} + +impl ChatWidget { + pub(super) fn user_message_display_from_parts( + message: String, + text_elements: Vec, + local_images: Vec, + remote_image_urls: Vec, + ) -> UserMessageDisplay { + UserMessageDisplay { + message, + remote_image_urls, + local_images, + text_elements, + } + } + + /// Build the compare key for a submitted pending steer without invoking the + /// expensive request-serialization path. Pending steers only need to match the + /// committed app-server `UserMessage` item emitted after input drains, which + /// preserves flattened text and total image count. + pub(super) fn pending_steer_compare_key_from_items( + items: &[UserInput], + ) -> PendingSteerCompareKey { + let mut message = String::new(); + let mut image_count = 0; + + for item in items { + match item { + UserInput::Text { text, .. } => message.push_str(text), + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + } + } + + PendingSteerCompareKey { + message, + image_count, + } + } + + pub(super) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay { + let mut message = String::new(); + let mut remote_image_urls = Vec::new(); + let mut local_images = Vec::new(); + let mut text_elements = Vec::new(); + + for item in items { + match item { + UserInput::Text { + text, + text_elements: current_text_elements, + } => append_text_with_rebased_elements( + &mut message, + &mut text_elements, + text, + current_text_elements.iter().map(|element| { + let range = element.byte_range.clone(); + TextElement::new( + range.clone().into(), + element + .placeholder() + .or_else(|| text.get(range.start..range.end)) + .map(str::to_string), + ) + }), + ), + UserInput::Image { url } => remote_image_urls.push(url.clone()), + UserInput::LocalImage { path } => local_images.push(path.clone()), + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + } + } + + Self::user_message_display_from_parts( + message, + text_elements, + local_images, + remote_image_urls, + ) + } +} diff --git a/codex-rs/tui/src/diff_model.rs b/codex-rs/tui/src/diff_model.rs new file mode 100644 index 0000000000..3b7055ea17 --- /dev/null +++ b/codex-rs/tui/src/diff_model.rs @@ -0,0 +1,21 @@ +//! Minimal file-change model used by TUI diff rendering and approval previews. + +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum FileChange { + Add { + content: String, + }, + Delete { + content: String, + }, + Update { + unified_diff: String, + move_path: Option, + }, +} diff --git a/codex-rs/tui/src/session_resume.rs b/codex-rs/tui/src/session_resume.rs new file mode 100644 index 0000000000..169a096d1e --- /dev/null +++ b/codex-rs/tui/src/session_resume.rs @@ -0,0 +1,314 @@ +//! Resolve saved-session state needed before resuming or forking a thread. +//! +//! The app-server API owns normal thread lifecycle data. This module coordinates +//! the TUI-specific cwd prompt and falls back to local rollout metadata only +//! before the app server has resumed the selected thread. + +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use crate::cwd_prompt; +use crate::cwd_prompt::CwdPromptAction; +use crate::cwd_prompt::CwdPromptOutcome; +use crate::cwd_prompt::CwdSelection; +use crate::legacy_core::config::Config; +use crate::tui::Tui; +use codex_protocol::ThreadId; +use codex_rollout::state_db::get_state_db; +use codex_utils_path as path_utils; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::AsyncBufReadExt; + +#[derive(Default)] +struct RolloutResumeState { + thread_id: Option, + cwd: Option, + model: Option, +} + +#[derive(Deserialize)] +struct SessionMetadata { + id: ThreadId, + cwd: PathBuf, +} + +#[derive(Deserialize)] +struct TurnContextResumeState { + cwd: PathBuf, + model: String, +} + +#[derive(Deserialize)] +struct RawRecord { + #[serde(rename = "type")] + item_type: String, + payload: Option, +} + +pub(crate) enum ResolveCwdOutcome { + Continue(Option), + Exit, +} + +pub(crate) async fn resolve_session_thread_id( + path: &Path, + id_str_if_uuid: Option<&str>, +) -> Option { + match id_str_if_uuid { + Some(id_str) => ThreadId::from_string(id_str).ok(), + None => read_rollout_resume_state(path) + .await + .ok() + .and_then(|state| state.thread_id), + } +} + +pub(crate) async fn read_session_model( + config: &Config, + thread_id: ThreadId, + path: Option<&Path>, +) -> Option { + if let Some(state_db_ctx) = get_state_db(config).await + && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await + && let Some(model) = metadata.model + { + return Some(model); + } + + let path = path?; + read_rollout_resume_state(path) + .await + .ok() + .and_then(|state| state.model) +} + +pub(crate) async fn resolve_cwd_for_resume_or_fork( + tui: &mut Tui, + config: &Config, + current_cwd: &Path, + thread_id: ThreadId, + path: Option<&Path>, + action: CwdPromptAction, + allow_prompt: bool, +) -> color_eyre::Result { + let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else { + return Ok(ResolveCwdOutcome::Continue(None)); + }; + if allow_prompt && cwds_differ(current_cwd, &history_cwd) { + let selection_outcome = + cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; + return Ok(match selection_outcome { + CwdPromptOutcome::Selection(CwdSelection::Current) => { + ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf())) + } + CwdPromptOutcome::Selection(CwdSelection::Session) => { + ResolveCwdOutcome::Continue(Some(history_cwd)) + } + CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit, + }); + } + Ok(ResolveCwdOutcome::Continue(Some(history_cwd))) +} + +async fn read_session_cwd( + config: &Config, + thread_id: ThreadId, + path: Option<&Path>, +) -> Option { + if let Some(state_db_ctx) = get_state_db(config).await + && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await + { + return Some(metadata.cwd); + } + + let path = path?; + match read_rollout_resume_state(path).await { + Ok(state) => state.cwd, + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + } +} + +pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { + !path_utils::paths_match_after_normalization(current_cwd, session_cwd) +} + +async fn read_rollout_resume_state(path: &Path) -> io::Result { + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + let mut state = RolloutResumeState::default(); + let mut saw_record = false; + + while let Some(line) = lines.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(trimmed) else { + continue; + }; + saw_record = true; + let Some(payload) = record.payload else { + continue; + }; + + match record.item_type.as_str() { + "session_meta" if state.thread_id.is_none() => { + if let Ok(metadata) = serde_json::from_value::(payload) { + state.thread_id = Some(metadata.id); + state.cwd.get_or_insert(metadata.cwd); + } + } + "turn_context" => { + if let Ok(turn_context) = serde_json::from_value::(payload) + { + state.cwd = Some(turn_context.cwd); + state.model = Some(turn_context.model); + } + } + _ => {} + } + } + + if saw_record { + Ok(state) + } else { + Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn rollout_line( + timestamp: &str, + item_type: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + serde_json::json!({ + "timestamp": timestamp, + "type": item_type, + "payload": payload, + }) + } + + fn write_rollout_lines(path: &Path, lines: &[serde_json::Value]) -> std::io::Result<()> { + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(path, text) + } + + #[tokio::test] + async fn rollout_resume_state_prefers_latest_turn_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let original = temp_dir.path().join("original"); + let latest = temp_dir.path().join("latest"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[ + rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "cwd": original, + "originator": "test", + "cli_version": "test", + }), + ), + rollout_line( + "t1", + "turn_context", + serde_json::json!({ "cwd": temp_dir.path().join("middle"), "model": "middle" }), + ), + rollout_line( + "t2", + "turn_context", + serde_json::json!({ "cwd": latest.clone(), "model": "latest" }), + ), + ], + )?; + + let state = read_rollout_resume_state(&rollout_path).await?; + + assert_eq!(state.thread_id, Some(thread_id)); + assert_eq!(state.cwd, Some(latest)); + assert_eq!(state.model, Some("latest".to_string())); + Ok(()) + } + + #[tokio::test] + async fn rollout_resume_state_falls_back_to_session_meta() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let cwd = temp_dir.path().join("session"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + write_rollout_lines( + &rollout_path, + &[rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "cwd": cwd.clone(), + "originator": "test", + "cli_version": "test", + }), + )], + )?; + + let state = read_rollout_resume_state(&rollout_path).await?; + + assert_eq!(state.thread_id, Some(thread_id)); + assert_eq!(state.cwd, Some(cwd)); + assert_eq!(state.model, None); + Ok(()) + } + + #[tokio::test] + async fn rollout_resume_state_skips_malformed_lines() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let thread_id = ThreadId::new(); + let cwd = temp_dir.path().join("session"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let valid_line = serde_json::to_string(&rollout_line( + "t0", + "session_meta", + serde_json::json!({ + "id": thread_id, + "cwd": cwd.clone(), + "originator": "test", + "cli_version": "test", + }), + )) + .expect("serialize rollout line"); + std::fs::write(&rollout_path, format!("{valid_line}\n{{"))?; + + let state = read_rollout_resume_state(&rollout_path).await?; + + assert_eq!(state.thread_id, Some(thread_id)); + assert_eq!(state.cwd, Some(cwd)); + Ok(()) + } +} diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs new file mode 100644 index 0000000000..ec0f7789d7 --- /dev/null +++ b/codex-rs/tui/src/session_state.rs @@ -0,0 +1,45 @@ +//! Canonical TUI session state shared across app-server routing, chat display, and status UI. +//! +//! The app-server API is the boundary for session lifecycle events. Once those responses enter +//! TUI, this module holds the small internal state shape used by app orchestration and widgets. + +use std::path::PathBuf; + +use codex_app_server_protocol::AskForApproval; +use codex_protocol::ThreadId; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SessionNetworkProxyRuntime { + pub(crate) http_addr: String, + pub(crate) socks_addr: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ThreadSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) forked_from_id: Option, + pub(crate) fork_parent_title: Option, + pub(crate) thread_name: Option, + pub(crate) model: String, + pub(crate) model_provider_id: String, + pub(crate) service_tier: Option, + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, + /// Canonical active permissions for this session. Legacy app-server + /// responses are converted to a profile at ingestion time using the + /// response cwd so cached sessions do not reinterpret cwd-bound grants. + pub(crate) permission_profile: PermissionProfile, + /// Named or implicit built-in profile that produced `permission_profile`, + /// when the server knows it. + pub(crate) active_permission_profile: Option, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) instruction_source_paths: Vec, + pub(crate) reasoning_effort: Option, + pub(crate) history_log_id: u64, + pub(crate) history_entry_count: u64, + pub(crate) network_proxy: Option, + pub(crate) rollout_path: Option, +} diff --git a/codex-rs/tui/src/token_usage.rs b/codex-rs/tui/src/token_usage.rs new file mode 100644 index 0000000000..14d3d78c6c --- /dev/null +++ b/codex-rs/tui/src/token_usage.rs @@ -0,0 +1,87 @@ +//! TUI token usage models and display formatting. + +use std::fmt; + +use codex_protocol::num_format::format_with_separators; +use serde::Deserialize; +use serde::Serialize; + +const BASELINE_TOKENS: i64 = 12000; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: i64, + pub cached_input_tokens: i64, + pub output_tokens: i64, + pub reasoning_output_tokens: i64, + pub total_tokens: i64, +} + +impl TokenUsage { + pub fn is_zero(&self) -> bool { + self.total_tokens == 0 + } + + pub(crate) fn cached_input(&self) -> i64 { + self.cached_input_tokens.max(0) + } + + pub(crate) fn non_cached_input(&self) -> i64 { + (self.input_tokens - self.cached_input()).max(0) + } + + pub(crate) fn blended_total(&self) -> i64 { + (self.non_cached_input() + self.output_tokens.max(0)).max(0) + } + + pub(crate) fn tokens_in_context_window(&self) -> i64 { + self.total_tokens + } + + pub(crate) fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 { + if context_window <= BASELINE_TOKENS { + return 0; + } + let effective_window = context_window - BASELINE_TOKENS; + let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + ((remaining as f64 / effective_window as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as i64 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TokenUsageInfo { + pub(crate) total_token_usage: TokenUsage, + pub(crate) last_token_usage: TokenUsage, + pub(crate) model_context_window: Option, +} + +impl fmt::Display for TokenUsage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Token usage: total={} input={}{} output={}{}", + format_with_separators(self.blended_total()), + format_with_separators(self.non_cached_input()), + if self.cached_input() > 0 { + format!( + " (+ {} cached)", + format_with_separators(self.cached_input()) + ) + } else { + String::new() + }, + format_with_separators(self.output_tokens), + if self.reasoning_output_tokens > 0 { + format!( + " (reasoning {})", + format_with_separators(self.reasoning_output_tokens) + ) + } else { + String::new() + } + ) + } +}