tui: add local protocol replacement models

This commit is contained in:
Eric Traut
2026-04-29 23:47:28 -07:00
parent ae863e72a2
commit 94c0997757
6 changed files with 693 additions and 0 deletions
+119
View File
@@ -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<String>,
#[serde(default)]
pub(crate) turn_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
#[serde(default)]
pub(crate) available_decisions: Option<Vec<CommandExecutionApprovalDecision>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) network_approval_context: Option<NetworkApprovalContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) additional_permissions: Option<AdditionalPermissionProfile>,
}
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<CommandExecutionApprovalDecision> {
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<CommandExecutionApprovalDecision> {
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<PathBuf, FileChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) grant_root: Option<PathBuf>,
}
@@ -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<String>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
}
#[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<TextElement>,
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> 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,
)
}
}
+21
View File
@@ -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<PathBuf>,
},
}
+314
View File
@@ -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<ThreadId>,
cwd: Option<PathBuf>,
model: Option<String>,
}
#[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<Value>,
}
pub(crate) enum ResolveCwdOutcome {
Continue(Option<PathBuf>),
Exit,
}
pub(crate) async fn resolve_session_thread_id(
path: &Path,
id_str_if_uuid: Option<&str>,
) -> Option<ThreadId> {
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<String> {
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<ResolveCwdOutcome> {
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<PathBuf> {
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<RolloutResumeState> {
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::<RawRecord>(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::<SessionMetadata>(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::<TurnContextResumeState>(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(())
}
}
+45
View File
@@ -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<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
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<ActivePermissionProfile>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
+87
View File
@@ -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<i64>,
}
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()
}
)
}
}