Rebase BTW TUI state onto current main

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-24 16:40:01 -07:00
co-authored by Codex
parent 312076fdf1
commit 9b8f7f1d09
11 changed files with 5981 additions and 2159 deletions
+715 -108
View File
File diff suppressed because it is too large Load Diff
+95 -2
View File
@@ -10,12 +10,18 @@
use std::path::PathBuf;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
@@ -25,7 +31,7 @@ use crate::chatwidget::UserMessage;
use crate::history_cell::HistoryCell;
use codex_core::config::types::ApprovalsReviewer;
use codex_core::features::Feature;
use codex_features::Feature;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
@@ -76,7 +82,6 @@ pub(crate) enum AppEvent {
OpenAgentPicker,
/// Switch the active thread to the selected agent.
SelectAgentThread(ThreadId),
/// Fork the current thread into a transient BTW child and submit a side-question there.
StartBtw {
parent_thread_id: ThreadId,
@@ -169,6 +174,84 @@ pub(crate) enum AppEvent {
force_refetch: bool,
},
/// Fetch plugin marketplace state for the provided working directory.
FetchPluginsList {
cwd: PathBuf,
},
/// Result of fetching plugin marketplace state.
PluginsLoaded {
cwd: PathBuf,
result: Result<PluginListResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
},
/// Fetch detail for a specific plugin from a marketplace.
FetchPluginDetail {
cwd: PathBuf,
params: PluginReadParams,
},
/// Result of fetching plugin detail.
PluginDetailLoaded {
cwd: PathBuf,
result: Result<PluginReadResponse, String>,
},
/// Replace the plugins popup with an install loading state.
OpenPluginInstallLoading {
plugin_display_name: String,
},
/// Replace the plugins popup with an uninstall loading state.
OpenPluginUninstallLoading {
plugin_display_name: String,
},
/// Install a specific plugin from a marketplace.
FetchPluginInstall {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
},
/// Result of installing a plugin.
PluginInstallLoaded {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
},
/// Uninstall a specific plugin by canonical plugin id.
FetchPluginUninstall {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
},
/// Result of uninstalling a plugin.
PluginUninstallLoaded {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
result: Result<PluginUninstallResponse, String>,
},
/// Advance the post-install plugin app-auth flow.
PluginInstallAuthAdvance {
refresh_connectors: bool,
},
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
@@ -459,6 +542,16 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed terminal-title item ordering/selection.
TerminalTitleSetup {
items: Vec<TerminalTitleItem>,
},
/// Apply a temporary terminal-title preview while the setup UI is open.
TerminalTitleSetupPreview {
items: Vec<TerminalTitleItem>,
},
/// Dismiss the terminal-title setup UI without changing config.
TerminalTitleSetupCancelled,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected {
File diff suppressed because it is too large Load Diff
+83 -15
View File
@@ -1,4 +1,6 @@
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::ChatWidget;
use crate::app_event::AppEvent;
@@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallResponse;
@@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
struct DelayedLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
}
impl DelayedLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
loading_text,
note,
}
}
}
impl Renderable for DelayedLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let mut lines = Vec::with_capacity(3);
lines.push(Line::from("Plugins".bold()));
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.started_at);
if elapsed < LOADING_ANIMATION_DELAY {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
lines.push(Line::from(self.loading_text.as_str().dim()));
} else if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
} else {
lines.push(Line::from(self.loading_text.as_str().dim()));
}
if let Some(note) = &self.note {
lines.push(Line::from(note.as_str().dim()));
}
Paragraph::new(lines).render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
2 + u16::from(self.note.is_some())
}
}
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
@@ -474,16 +545,14 @@ impl ChatWidget {
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"Available marketplaces will appear here when ready.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
"Loading available plugins...".to_string(),
Some("This first pass shows the ChatGPT marketplace only.".to_string()),
)),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
@@ -495,15 +564,14 @@ impl ChatWidget {
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
format!("Loading details for {plugin_display_name}..."),
/*note*/ None,
)),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some("This updates when plugin details load.".to_string()),
@@ -4,6 +4,6 @@ expression: popup
---
Plugins
Loading available plugins...
Available marketplaces will appear here when ready.
This first pass shows the ChatGPT marketplace only.
› Loading plugins... This updates when the marketplace list is ready.
File diff suppressed because it is too large Load Diff
+14 -45
View File
@@ -151,13 +151,11 @@ impl App {
let channel = self.thread_event_channels.get(&parent_thread_id)?;
let store = channel.store.lock().await;
match store.session_configured.as_ref().map(|event| &event.msg) {
Some(EventMsg::SessionConfigured(session)) => session
.thread_name
.clone()
.filter(|name| !name.trim().is_empty()),
_ => None,
}
store
.session
.as_ref()
.and_then(|session| session.thread_name.clone())
.filter(|name| !name.trim().is_empty())
}
pub(super) async fn select_agent_thread_and_discard_btw_chain(
@@ -167,7 +165,7 @@ impl App {
thread_id: ThreadId,
) -> Result<()> {
let btw_threads_to_discard = self.btw_threads_to_discard_after_switch(thread_id);
self.select_agent_thread(tui, thread_id).await?;
self.select_agent_thread(tui, app_server, thread_id).await?;
if self.active_thread_id == Some(thread_id) {
for btw_thread_id in btw_threads_to_discard {
self.discard_btw_thread(app_server, btw_thread_id).await;
@@ -193,45 +191,16 @@ impl App {
let mut fork_config = self.config.clone();
fork_config.ephemeral = true;
let fork_result = match app_server.fork_thread(fork_config, parent_thread_id).await {
Ok(forked) => Ok(forked),
Err(err) => {
if self.current_displayed_thread_id() != Some(parent_thread_id) {
self.chat_widget.add_error_message(format!(
"Failed to fork BTW thread from {parent_thread_id}: {err}"
));
return Ok(AppRunControl::Continue);
}
let Some(parent_rollout_path) =
self.chat_widget.rollout_path().filter(|path| path.exists())
else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before /btw can fork it."
.to_string(),
);
return Ok(AppRunControl::Continue);
};
let mut fork_config = self.config.clone();
fork_config.ephemeral = true;
app_server
.fork_thread_from_path(fork_config, parent_thread_id, parent_rollout_path)
.await
}
};
match fork_result {
match app_server.fork_thread(fork_config, parent_thread_id).await {
Ok(forked) => {
let child_thread_id = forked.session_configured.session_id;
let child_thread_id = forked.session.thread_id;
let next_fork_banner_parent_label =
self.fork_banner_parent_label(parent_thread_id).await;
self.enqueue_thread_event(
child_thread_id,
Event {
id: String::new(),
msg: EventMsg::SessionConfigured(forked.session_configured),
},
)
.await?;
let channel = self.ensure_thread_channel(child_thread_id);
{
let mut store = channel.store.lock().await;
store.set_session(forked.session, forked.turns);
}
self.btw_threads.insert(
child_thread_id,
BtwThreadState {
@@ -259,7 +228,7 @@ impl App {
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to start BTW thread from {parent_thread_id}: {err}"
"Failed to fork BTW thread from {parent_thread_id}: {err}"
));
}
}
+179 -246
View File
@@ -1,6 +1,7 @@
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
@@ -42,10 +43,13 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -53,17 +57,9 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_core::config::Config;
use codex_core::message_history;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
@@ -73,19 +69,18 @@ use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionNetworkProxyRuntime;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::debug;
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
@@ -108,6 +103,25 @@ pub(crate) struct AppServerSession {
next_request_id: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
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,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) cwd: PathBuf,
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>,
}
#[derive(Clone, Copy)]
enum ThreadParamsMode {
Embedded,
@@ -124,7 +138,8 @@ impl ThreadParamsMode {
}
pub(crate) struct AppServerStartedThread {
pub(crate) session_configured: SessionConfiguredEvent,
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
}
impl AppServerSession {
@@ -164,16 +179,6 @@ impl AppServerSession {
})
.await
.wrap_err("model/list failed during TUI bootstrap")?;
let rate_limit_request_id = self.next_request_id();
let rate_limits: GetAccountRateLimitsResponse = self
.client
.request_typed(ClientRequest::GetAccountRateLimits {
request_id: rate_limit_request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed during TUI bootstrap")?;
let available_models = models
.data
.into_iter()
@@ -238,6 +243,25 @@ impl AppServerSession {
false,
),
};
let rate_limit_snapshots = if account.requires_openai_auth && has_chatgpt_account {
let rate_limit_request_id = self.next_request_id();
match self
.client
.request_typed(ClientRequest::GetAccountRateLimits {
request_id: rate_limit_request_id,
params: None,
})
.await
{
Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits),
Err(error) => {
debug!(error = ?error, "failed to fetch rate limits during TUI bootstrap");
Vec::new()
}
}
} else {
Vec::new()
};
Ok(AppServerBootstrap {
account_auth_mode,
@@ -249,7 +273,7 @@ impl AppServerSession {
feedback_audience,
has_chatgpt_account,
available_models,
rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits),
rate_limit_snapshots,
})
}
@@ -267,7 +291,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(&response)
started_thread_from_start_response(response, config).await
}
pub(crate) async fn resume_thread(
@@ -275,21 +299,20 @@ impl AppServerSession {
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
let request_id = self.next_request_id();
let response: ThreadResumeResponse = self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config,
config.clone(),
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
started_thread_from_resume_response(&response, show_raw_agent_reasoning)
started_thread_from_resume_response(response, &config).await
}
pub(crate) async fn fork_thread(
@@ -297,37 +320,20 @@ impl AppServerSession {
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
self.fork_thread_with_path(config, thread_id, /*path*/ None)
.await
}
pub(crate) async fn fork_thread_from_path(
&mut self,
config: Config,
thread_id: ThreadId,
path: PathBuf,
) -> Result<AppServerStartedThread> {
self.fork_thread_with_path(config, thread_id, Some(path))
.await
}
async fn fork_thread_with_path(
&mut self,
config: Config,
thread_id: ThreadId,
path: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
let request_id = self.next_request_id();
let mut params =
thread_fork_params_from_config(config, thread_id, self.thread_params_mode());
params.path = path;
let response: ThreadForkResponse = self
.client
.request_typed(ClientRequest::ThreadFork { request_id, params })
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
started_thread_from_fork_response(&response, show_raw_agent_reasoning)
started_thread_from_fork_response(response, &config).await
}
fn thread_params_mode(&self) -> ThreadParamsMode {
@@ -434,7 +440,7 @@ impl AppServerSession {
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> Result<TurnSteerResponse> {
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnSteer {
@@ -446,7 +452,6 @@ impl AppServerSession {
},
})
.await
.wrap_err("turn/steer failed in app-server TUI")
}
pub(crate) async fn thread_set_name(
@@ -499,6 +504,26 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_shell_command(
&mut self,
thread_id: ThreadId,
command: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadShellCommandResponse = self
.client
.request_typed(ClientRequest::ThreadShellCommand {
request_id,
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
},
})
.await
.wrap_err("thread/shellCommand failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
@@ -851,54 +876,50 @@ fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode)
}
}
fn started_thread_from_start_response(
response: &ThreadStartResponse,
async fn started_thread_from_start_response(
response: ThreadStartResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_start_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread { session_configured })
}
fn started_thread_from_resume_response(
response: &ThreadResumeResponse,
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_resume_response(response)
let session = thread_session_state_from_thread_start_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
session,
turns: response.thread.turns,
})
}
fn started_thread_from_fork_response(
response: &ThreadForkResponse,
show_raw_agent_reasoning: bool,
async fn started_thread_from_resume_response(
response: ThreadResumeResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_fork_response(response)
let session = thread_session_state_from_thread_resume_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
session,
turns: response.thread.turns,
})
}
fn session_configured_from_thread_start_response(
async fn started_thread_from_fork_response(
response: ThreadForkResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_fork_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn thread_session_state_from_thread_start_response(
response: &ThreadStartResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
@@ -910,13 +931,16 @@ fn session_configured_from_thread_start_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn session_configured_from_thread_resume_response(
async fn thread_session_state_from_thread_resume_response(
response: &ThreadResumeResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
@@ -928,13 +952,16 @@ fn session_configured_from_thread_resume_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn session_configured_from_thread_fork_response(
async fn thread_session_state_from_thread_fork_response(
response: &ThreadForkResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
@@ -946,7 +973,9 @@ fn session_configured_from_thread_fork_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn review_target_to_app_server(
@@ -972,7 +1001,7 @@ fn review_target_to_app_server(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
fn session_configured_from_thread_response(
async fn thread_session_state_from_thread_response(
thread_id: &str,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
@@ -984,12 +1013,15 @@ fn session_configured_from_thread_response(
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
) -> Result<SessionConfiguredEvent, String> {
let session_id = ThreadId::from_string(thread_id)
config: &Config,
) -> Result<ThreadSessionState, String> {
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
let (history_log_id, history_entry_count) = message_history::history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
Ok(SessionConfiguredEvent {
session_id,
Ok(ThreadSessionState {
thread_id,
forked_from_id: None,
thread_name,
model,
@@ -1000,129 +1032,13 @@ fn session_configured_from_thread_response(
sandbox_policy,
cwd,
reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
history_log_id,
history_entry_count,
network_proxy: None,
rollout_path,
})
}
fn thread_initial_messages(
thread_id: &ThreadId,
turns: &[codex_app_server_protocol::Turn],
show_raw_agent_reasoning: bool,
) -> Option<Vec<EventMsg>> {
let events: Vec<EventMsg> = turns
.iter()
.flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning))
.collect();
(!events.is_empty()).then_some(events)
}
fn turn_initial_messages(
thread_id: &ThreadId,
turn: &codex_app_server_protocol::Turn,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
turn.items
.iter()
.cloned()
.filter_map(app_server_thread_item_to_core)
.flat_map(|item| match item {
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: *thread_id,
turn_id: turn.id.clone(),
item: TurnItem::Plan(item),
})],
item => item.as_legacy_events(show_raw_agent_reasoning),
})
.collect()
}
fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option<TurnItem> {
match item {
codex_app_server_protocol::ThreadItem::UserMessage { id, content } => {
Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
}))
}
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
codex_app_server_protocol::ThreadItem::Plan { id, text } => {
Some(TurnItem::Plan(PlanItem { id, text }))
}
codex_app_server_protocol::ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => {
Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
}))
}
codex_app_server_protocol::ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
codex_app_server_protocol::ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
codex_app_server_protocol::ThreadItem::CommandExecution { .. }
| codex_app_server_protocol::ThreadItem::FileChange { .. }
| codex_app_server_protocol::ThreadItem::McpToolCall { .. }
| codex_app_server_protocol::ThreadItem::DynamicToolCall { .. }
| codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. }
| codex_app_server_protocol::ThreadItem::ImageView { .. }
| codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. }
| codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None,
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => {
Some(codex_protocol::models::WebSearchAction::Other)
}
}
}
fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
@@ -1219,8 +1135,10 @@ mod tests {
assert_eq!(fork.model_provider, None);
}
#[test]
fn resume_response_restores_initial_messages_from_turn_items() {
#[tokio::test]
async fn resume_response_restores_turns_from_thread_items() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
@@ -1253,6 +1171,7 @@ mod tests {
id: "assistant-1".to_string(),
text: "assistant reply".to_string(),
phase: None,
memory_citation: None,
},
],
status: TurnStatus::Completed,
@@ -1269,30 +1188,44 @@ mod tests {
reasoning_effort: None,
};
let started =
started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false)
.expect("resume response should map");
let initial_messages = started
.session_configured
.initial_messages
.expect("resume response should restore replay history");
let started = started_thread_from_resume_response(response.clone(), &config)
.await
.expect("resume response should map");
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
}
assert_eq!(initial_messages.len(), 2);
match &initial_messages[0] {
EventMsg::UserMessage(event) => {
assert_eq!(event.message, "hello from history");
assert_eq!(event.images.as_ref(), Some(&Vec::new()));
assert!(event.local_images.is_empty());
assert!(event.text_elements.is_empty());
}
other => panic!("expected replayed user message, got {other:?}"),
}
match &initial_messages[1] {
EventMsg::AgentMessage(event) => {
assert_eq!(event.message, "assistant reply");
assert_eq!(event.phase, None);
}
other => panic!("expected replayed agent message, got {other:?}"),
}
#[tokio::test]
async fn session_configured_populates_history_metadata() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
message_history::append_entry("older", &thread_id, &config)
.await
.expect("history append should succeed");
message_history::append_entry("newer", &thread_id, &config)
.await
.expect("history append should succeed");
let session = thread_session_state_from_thread_response(
&thread_id.to_string(),
Some("restore".to_string()),
None,
"gpt-5.4".to_string(),
"openai".to_string(),
None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
PathBuf::from("/tmp/project"),
None,
&config,
)
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,6 @@
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::ChatWidget;
use crate::app_event::AppEvent;
@@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallResponse;
@@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
struct DelayedLoadingHeader {
started_at: Instant,
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
}
impl DelayedLoadingHeader {
fn new(
frame_requester: FrameRequester,
animations_enabled: bool,
loading_text: String,
note: Option<String>,
) -> Self {
Self {
started_at: Instant::now(),
frame_requester,
animations_enabled,
loading_text,
note,
}
}
}
impl Renderable for DelayedLoadingHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let mut lines = Vec::with_capacity(3);
lines.push(Line::from("Plugins".bold()));
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.started_at);
if elapsed < LOADING_ANIMATION_DELAY {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
lines.push(Line::from(self.loading_text.as_str().dim()));
} else if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
} else {
lines.push(Line::from(self.loading_text.as_str().dim()));
}
if let Some(note) = &self.note {
lines.push(Line::from(note.as_str().dim()));
}
Paragraph::new(lines).render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
2 + u16::from(self.note.is_some())
}
}
#[derive(Debug, Clone, Default)]
pub(super) enum PluginsCacheState {
@@ -474,16 +545,14 @@ impl ChatWidget {
}
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from("Loading available plugins...".dim()));
header.push(Line::from(
"Available marketplaces will appear here when ready.".dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
"Loading available plugins...".to_string(),
Some("This first pass shows the ChatGPT marketplace only.".to_string()),
)),
items: vec![SelectionItem {
name: "Loading plugins...".to_string(),
description: Some("This updates when the marketplace list is ready.".to_string()),
@@ -495,15 +564,14 @@ impl ChatWidget {
}
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
format!("Loading details for {plugin_display_name}...").dim(),
));
SelectionViewParams {
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
header: Box::new(header),
header: Box::new(DelayedLoadingHeader::new(
self.frame_requester.clone(),
self.config.animations,
format!("Loading details for {plugin_display_name}..."),
/*note*/ None,
)),
items: vec![SelectionItem {
name: "Loading plugin details...".to_string(),
description: Some("This updates when plugin details load.".to_string()),
@@ -4,6 +4,6 @@ expression: popup
---
Plugins
Loading available plugins...
Available marketplaces will appear here when ready.
This first pass shows the ChatGPT marketplace only.
› Loading plugins... This updates when the marketplace list is ready.