Refactor TUI app module into submodules (#18753)

## Why

The TUI app module had grown past the 512K source-file cap enforced by
CI/CD. This keeps the app entry point below that limit while preserving
the existing runtime behavior and test surface.

## What changed

- Kept the top-level `App` state and run-loop wiring in
`tui/src/app.rs`.
- Split app responsibilities into focused private submodules under
`tui/src/app/`, covering event dispatch, thread routing, session
lifecycle, config persistence, background requests, startup prompts,
input, history UI, platform actions, and thread event buffering.
- Moved the existing app-level tests into `tui/src/app/tests.rs` and
reused the existing snapshot location rather than adding new tests or
snapshots.
- Added module header comments for `app.rs` and the new submodules.

## Follow-up

A future cleanup can move narrow unit tests from `tui/src/app/tests.rs`
into the specific app submodules they exercise. This PR keeps the
existing app-level tests together so the refactor stays focused on the
source-file split.

## Verification

- `cargo test -p codex-tui --lib
app::tests::agent_picker_item_name_snapshot`
- `cargo test -p codex-tui --lib app::tests::clear_ui`
- `cargo test -p codex-tui --lib
app::tests::ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot`
- `just fix -p codex-tui`

Full `cargo test -p codex-tui` still fails on model-catalog drift
unrelated to this refactor, including stale
`gpt-5.3-codex`/`gpt-5.1-codex` snapshot and migration expectations now
resolving to `gpt-5.4`.
This commit is contained in:
Eric Traut
2026-04-20 16:10:35 -07:00
committed by GitHub
Unverified
parent 7b994100b3
commit 2af4f15479
12 changed files with 11426 additions and 11347 deletions
+21 -11347
View File
File diff suppressed because it is too large Load Diff
+639
View File
@@ -0,0 +1,639 @@
//! Background app-server requests launched by the TUI app.
//!
//! This module owns fire-and-forget fetch/write helpers for MCP inventory, skills, plugins, rate
//! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so
//! the main event loop remains single-threaded.
use super::*;
impl App {
pub(super) fn fetch_mcp_inventory(
&mut self,
app_server: &AppServerSession,
detail: McpServerStatusDetail,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_all_mcp_server_statuses(request_handle, detail)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::McpInventoryLoaded { result, detail });
});
}
/// Spawns a background task to fetch account rate limits and deliver the
/// result as a `RateLimitsLoaded` event.
///
/// The `origin` is forwarded to the completion handler so it can distinguish
/// a startup prefetch (which only updates cached snapshots and schedules a
/// frame) from a `/status`-triggered refresh (which must finalize the
/// corresponding status card).
pub(super) fn refresh_rate_limits(
&mut self,
app_server: &AppServerSession,
origin: RateLimitRefreshOrigin,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_account_rate_limits(request_handle)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result });
});
}
pub(super) fn send_add_credits_nudge_email(
&mut self,
app_server: &AppServerSession,
credit_type: AddCreditsNudgeCreditType,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = send_add_credits_nudge_email(request_handle, credit_type)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result });
});
}
/// Starts the initial skills refresh without delaying the first interactive frame.
///
/// Startup only needs skill metadata to populate skill mentions and the skills UI; the prompt can be
/// rendered before that metadata arrives. The result is routed through the normal app event queue so
/// the same response handler updates the chat widget and emits invalid `SKILL.md` warnings once the
/// app-server RPC finishes. User-initiated skills refreshes still use the blocking app command path so
/// callers that explicitly asked for fresh skill state do not race ahead of their own refresh.
pub(super) fn refresh_startup_skills(&mut self, app_server: &AppServerSession) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let cwd = self.config.cwd.to_path_buf();
tokio::spawn(async move {
let result = fetch_skills_list(request_handle, cwd)
.await
.map_err(|err| format!("{err:#}"));
app_event_tx.send(AppEvent::SkillsListLoaded { result });
});
}
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_plugins_list(request_handle, cwd.clone())
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::PluginsLoaded { cwd, result });
});
}
pub(super) fn fetch_plugin_detail(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
params: PluginReadParams,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_plugin_detail(request_handle, params)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::PluginDetailLoaded { cwd, result });
});
}
pub(super) fn fetch_plugin_install(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let cwd_for_event = cwd.clone();
let marketplace_path_for_event = marketplace_path.clone();
let plugin_name_for_event = plugin_name.clone();
let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name)
.await
.map_err(|err| format!("Failed to install plugin: {err}"));
app_event_tx.send(AppEvent::PluginInstallLoaded {
cwd: cwd_for_event,
marketplace_path: marketplace_path_for_event,
plugin_name: plugin_name_for_event,
plugin_display_name,
result,
});
});
}
pub(super) fn fetch_plugin_uninstall(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let cwd_for_event = cwd.clone();
let plugin_id_for_event = plugin_id.clone();
let result = fetch_plugin_uninstall(request_handle, plugin_id)
.await
.map_err(|err| format!("Failed to uninstall plugin: {err}"));
app_event_tx.send(AppEvent::PluginUninstallLoaded {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
plugin_display_name,
result,
});
});
}
pub(super) fn set_plugin_enabled(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&plugin_id) {
*queued_enabled = Some(enabled);
return;
}
self.pending_plugin_enabled_writes
.insert(plugin_id.clone(), None);
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled);
}
pub(super) fn spawn_plugin_enabled_write(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let cwd_for_event = cwd.clone();
let plugin_id_for_event = plugin_id.clone();
let result = write_plugin_enabled(request_handle, plugin_id, enabled)
.await
.map(|_| ())
.map_err(|err| format!("Failed to update plugin config: {err}"));
app_event_tx.send(AppEvent::PluginEnabledSet {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
enabled,
result,
});
});
}
pub(super) fn refresh_plugin_mentions(&mut self) {
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
if !config.features.enabled(Feature::Plugins) {
app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None });
return;
}
tokio::spawn(async move {
let plugins = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&config)
.await
.capability_summaries()
.to_vec();
app_event_tx.send(AppEvent::PluginMentionsLoaded {
plugins: Some(plugins),
});
});
}
pub(super) fn submit_feedback(
&mut self,
app_server: &AppServerSession,
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
include_logs: bool,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let origin_thread_id = self.chat_widget.thread_id();
let rollout_path = if include_logs {
self.chat_widget.rollout_path()
} else {
None
};
let params = build_feedback_upload_params(
origin_thread_id,
rollout_path,
category,
reason,
turn_id,
include_logs,
);
tokio::spawn(async move {
let result = fetch_feedback_upload(request_handle, params)
.await
.map(|response| response.thread_id)
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::FeedbackSubmitted {
origin_thread_id,
category,
include_logs,
result,
});
});
}
pub(super) fn handle_feedback_thread_event(&mut self, event: FeedbackThreadEvent) {
match event.result {
Ok(thread_id) => {
self.chat_widget
.add_to_history(crate::bottom_pane::feedback_success_cell(
event.category,
event.include_logs,
&thread_id,
event.feedback_audience,
))
}
Err(err) => self
.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to upload feedback: {err}"
))),
}
}
pub(super) async fn enqueue_thread_feedback_event(
&mut self,
thread_id: ThreadId,
event: FeedbackThreadEvent,
) {
let (sender, store) = {
let channel = self.ensure_thread_channel(thread_id);
(channel.sender.clone(), Arc::clone(&channel.store))
};
let should_send = {
let mut guard = store.lock().await;
guard
.buffer
.push_back(ThreadBufferedEvent::FeedbackSubmission(event.clone()));
if guard.buffer.len() > guard.capacity
&& let Some(removed) = guard.buffer.pop_front()
&& let ThreadBufferedEvent::Request(request) = &removed
{
guard
.pending_interactive_replay
.note_evicted_server_request(request);
}
guard.active
};
if should_send {
match sender.try_send(ThreadBufferedEvent::FeedbackSubmission(event)) {
Ok(()) => {}
Err(TrySendError::Full(event)) => {
tokio::spawn(async move {
if let Err(err) = sender.send(event).await {
tracing::warn!("thread {thread_id} event channel closed: {err}");
}
});
}
Err(TrySendError::Closed(_)) => {
tracing::warn!("thread {thread_id} event channel closed");
}
}
}
}
pub(super) async fn handle_feedback_submitted(
&mut self,
origin_thread_id: Option<ThreadId>,
category: FeedbackCategory,
include_logs: bool,
result: Result<String, String>,
) {
let event = FeedbackThreadEvent {
category,
include_logs,
feedback_audience: self.feedback_audience,
result,
};
if let Some(thread_id) = origin_thread_id {
self.enqueue_thread_feedback_event(thread_id, event).await;
} else {
self.handle_feedback_thread_event(event);
}
}
/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
/// When both the local config and the app-server report zero servers, a special
/// "empty" cell is shown instead of the full table.
pub(super) fn handle_mcp_inventory_result(
&mut self,
result: Result<Vec<McpServerStatus>, String>,
detail: McpServerStatusDetail,
) {
let config = self.chat_widget.config_ref().clone();
self.chat_widget.clear_mcp_inventory_loading();
self.clear_committed_mcp_inventory_loading();
let statuses = match result {
Ok(statuses) => statuses,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to load MCP inventory: {err}"));
return;
}
};
if config.mcp_servers.get().is_empty() && statuses.is_empty() {
self.chat_widget
.add_to_history(history_cell::empty_mcp_output());
return;
}
self.chat_widget
.add_to_history(history_cell::new_mcp_tools_output_from_statuses(
&config, &statuses, detail,
));
}
pub(super) fn clear_committed_mcp_inventory_loading(&mut self) {
let Some(index) = self
.transcript_cells
.iter()
.rposition(|cell| cell.as_any().is::<history_cell::McpInventoryLoadingCell>())
else {
return;
};
self.transcript_cells.remove(index);
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
overlay.replace_cells(self.transcript_cells.clone());
}
}
}
pub(super) async fn fetch_all_mcp_server_statuses(
request_handle: AppServerRequestHandle,
detail: McpServerStatusDetail,
) -> Result<Vec<McpServerStatus>> {
let mut cursor = None;
let mut statuses = Vec::new();
loop {
let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4()));
let response: ListMcpServerStatusResponse = request_handle
.request_typed(ClientRequest::McpServerStatusList {
request_id,
params: ListMcpServerStatusParams {
cursor: cursor.clone(),
limit: Some(100),
detail: Some(detail),
},
})
.await
.wrap_err("mcpServerStatus/list failed in TUI")?;
statuses.extend(response.data);
if let Some(next_cursor) = response.next_cursor {
cursor = Some(next_cursor);
} else {
break;
}
}
Ok(statuses)
}
pub(super) async fn fetch_account_rate_limits(
request_handle: AppServerRequestHandle,
) -> Result<Vec<RateLimitSnapshot>> {
let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4()));
let response: GetAccountRateLimitsResponse = request_handle
.request_typed(ClientRequest::GetAccountRateLimits {
request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed in TUI")?;
Ok(app_server_rate_limit_snapshots_to_core(response))
}
pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType,
) -> Result<codex_app_server_protocol::AddCreditsNudgeEmailStatus> {
let request_id = RequestId::String(format!("add-credits-nudge-{}", Uuid::new_v4()));
let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle
.request_typed(ClientRequest::SendAddCreditsNudgeEmail {
request_id,
params: SendAddCreditsNudgeEmailParams { credit_type },
})
.await
.wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?;
Ok(response.status)
}
pub(super) async fn fetch_skills_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<SkillsListResponse> {
let request_id = RequestId::String(format!("startup-skills-list-{}", Uuid::new_v4()));
// Use the cloneable request handle so startup can issue this RPC from a background task without
// extending a borrow of `AppServerSession` across the first frame render.
request_handle
.request_typed(ClientRequest::SkillsList {
request_id,
params: SkillsListParams {
cwds: vec![cwd],
force_reload: true,
per_cwd_extra_user_roots: None,
},
})
.await
.wrap_err("skills/list failed in TUI")
}
pub(super) async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<PluginListResponse> {
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?;
let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4()));
let mut response = request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
},
})
.await
.wrap_err("plugin/list failed in TUI")?;
hide_cli_only_plugin_marketplaces(&mut response);
Ok(response)
}
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
response
.marketplaces
.retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str()));
}
pub(super) async fn fetch_plugin_detail(
request_handle: AppServerRequestHandle,
params: PluginReadParams,
) -> Result<PluginReadResponse> {
let request_id = RequestId::String(format!("plugin-read-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginRead { request_id, params })
.await
.wrap_err("plugin/read failed in TUI")
}
pub(super) async fn fetch_plugin_install(
request_handle: AppServerRequestHandle,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
) -> Result<PluginInstallResponse> {
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginInstall {
request_id,
params: PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name,
},
})
.await
.wrap_err("plugin/install failed in TUI")
}
pub(super) async fn fetch_plugin_uninstall(
request_handle: AppServerRequestHandle,
plugin_id: String,
) -> Result<PluginUninstallResponse> {
let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginUninstall {
request_id,
params: PluginUninstallParams { plugin_id },
})
.await
.wrap_err("plugin/uninstall failed in TUI")
}
pub(super) async fn write_plugin_enabled(
request_handle: AppServerRequestHandle,
plugin_id: String,
enabled: bool,
) -> Result<ConfigWriteResponse> {
let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConfigValueWrite {
request_id,
params: ConfigValueWriteParams {
key_path: format!("plugins.{plugin_id}"),
value: serde_json::json!({ "enabled": enabled }),
merge_strategy: MergeStrategy::Upsert,
file_path: None,
expected_version: None,
},
})
.await
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
}
pub(super) fn build_feedback_upload_params(
origin_thread_id: Option<ThreadId>,
rollout_path: Option<PathBuf>,
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
include_logs: bool,
) -> FeedbackUploadParams {
let extra_log_files = if include_logs {
rollout_path.map(|rollout_path| vec![rollout_path])
} else {
None
};
let tags = turn_id.map(|turn_id| BTreeMap::from([(String::from("turn_id"), turn_id)]));
FeedbackUploadParams {
classification: crate::bottom_pane::feedback_classification(category).to_string(),
reason,
thread_id: origin_thread_id.map(|thread_id| thread_id.to_string()),
include_logs,
extra_log_files,
tags,
}
}
pub(super) async fn fetch_feedback_upload(
request_handle: AppServerRequestHandle,
params: FeedbackUploadParams,
) -> Result<FeedbackUploadResponse> {
let request_id = RequestId::String(format!("feedback-upload-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::FeedbackUpload { request_id, params })
.await
.wrap_err("feedback/upload failed in TUI")
}
/// Convert flat `McpServerStatus` responses into the per-server maps used by the
/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus
/// per-server resource/template/auth maps). Test-only because the TUI
/// renders directly from `McpServerStatus` rather than these maps.
#[cfg(test)]
pub(super) type McpInventoryMaps = (
HashMap<String, codex_protocol::mcp::Tool>,
HashMap<String, Vec<codex_protocol::mcp::Resource>>,
HashMap<String, Vec<codex_protocol::mcp::ResourceTemplate>>,
HashMap<String, McpAuthStatus>,
);
#[cfg(test)]
pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -> McpInventoryMaps {
let mut tools = HashMap::new();
let mut resources = HashMap::new();
let mut resource_templates = HashMap::new();
let mut auth_statuses = HashMap::new();
for status in statuses {
let server_name = status.name;
auth_statuses.insert(
server_name.clone(),
match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
},
);
resources.insert(server_name.clone(), status.resources);
resource_templates.insert(server_name.clone(), status.resource_templates);
for (tool_name, tool) in status.tools {
tools.insert(format!("mcp__{server_name}__{tool_name}"), tool);
}
}
(tools, resources, resource_templates, auth_statuses)
}
+539
View File
@@ -0,0 +1,539 @@
//! Runtime configuration persistence helpers for the TUI app.
//!
//! This module owns the app-level glue between config.toml edits, in-memory `Config` refreshes,
//! and the ChatWidget copy of session settings, keeping persistence-heavy code out of the main app
//! loop.
use super::*;
impl App {
pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
let mut overrides = self.harness_overrides.clone();
overrides.cwd = Some(cwd.clone());
let cwd_display = cwd.display().to_string();
ConfigBuilder::default()
.codex_home(self.config.codex_home.to_path_buf())
.cli_overrides(self.cli_kv_overrides.clone())
.harness_overrides(overrides)
.build()
.await
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
}
pub(super) async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> {
let mut config = self
.rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf())
.await?;
self.apply_runtime_policy_overrides(&mut config);
self.config = config;
self.chat_widget.sync_plugin_mentions_config(&self.config);
Ok(())
}
pub(super) async fn refresh_in_memory_config_from_disk_best_effort(&mut self, action: &str) {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
action,
"failed to refresh config before thread transition; continuing with current in-memory config"
);
}
}
pub(super) async fn rebuild_config_for_resume_or_fallback(
&mut self,
current_cwd: &Path,
resume_cwd: PathBuf,
) -> Result<Config> {
match self.rebuild_config_for_cwd(resume_cwd.clone()).await {
Ok(config) => Ok(config),
Err(err) => {
if crate::cwds_differ(current_cwd, &resume_cwd) {
Err(err)
} else {
let resume_cwd_display = resume_cwd.display().to_string();
tracing::warn!(
error = %err,
cwd = %resume_cwd_display,
"failed to rebuild config for same-cwd resume; using current in-memory config"
);
Ok(self.config.clone())
}
}
}
}
pub(super) fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(*policy)
{
tracing::warn!(%err, "failed to carry forward approval policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward approval policy override: {err}"
));
}
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
&& let Err(err) = config.permissions.sandbox_policy.set(policy.clone())
{
tracing::warn!(%err, "failed to carry forward sandbox policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward sandbox policy override: {err}"
));
}
}
pub(super) fn set_approvals_reviewer_in_app_and_widget(&mut self, reviewer: ApprovalsReviewer) {
self.config.approvals_reviewer = reviewer;
self.chat_widget.set_approvals_reviewer(reviewer);
}
pub(super) fn try_set_approval_policy_on_config(
&mut self,
config: &mut Config,
policy: AskForApproval,
user_message_prefix: &str,
log_message: &str,
) -> bool {
if let Err(err) = config.permissions.approval_policy.set(policy) {
tracing::warn!(error = %err, "{log_message}");
self.chat_widget
.add_error_message(format!("{user_message_prefix}: {err}"));
return false;
}
true
}
pub(super) fn try_set_sandbox_policy_on_config(
&mut self,
config: &mut Config,
policy: SandboxPolicy,
user_message_prefix: &str,
log_message: &str,
) -> bool {
if let Err(err) = config.permissions.sandbox_policy.set(policy) {
tracing::warn!(error = %err, "{log_message}");
self.chat_widget
.add_error_message(format!("{user_message_prefix}: {err}"));
return false;
}
true
}
pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) {
if updates.is_empty() {
return;
}
let guardian_approvals_preset = guardian_approvals_mode();
let mut next_config = self.config.clone();
let active_profile = self.active_profile.clone();
let scoped_segments = |key: &str| {
if let Some(profile) = active_profile.as_deref() {
vec!["profiles".to_string(), profile.to_string(), key.to_string()]
} else {
vec![key.to_string()]
}
};
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
matches!(
feature,
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
)
});
let mut approval_policy_override = None;
let mut approvals_reviewer_override = None;
let mut sandbox_policy_override = None;
let mut feature_updates_to_apply = Vec::with_capacity(updates.len());
// Auto-Review owns `approvals_reviewer`, but disabling the feature
// from inside a profile should not silently clear a value configured at
// the root scope.
let (root_approvals_reviewer_blocks_profile_disable, profile_approvals_reviewer_configured) = {
let effective_config = next_config.config_layer_stack.effective_config();
let root_blocks_disable = effective_config
.as_table()
.and_then(|table| table.get("approvals_reviewer"))
.is_some_and(|value| value != &TomlValue::String("user".to_string()));
let profile_configured = active_profile.as_deref().is_some_and(|profile| {
effective_config
.as_table()
.and_then(|table| table.get("profiles"))
.and_then(TomlValue::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(TomlValue::as_table)
.is_some_and(|profile_config| profile_config.contains_key("approvals_reviewer"))
});
(root_blocks_disable, profile_configured)
};
let mut permissions_history_label: Option<&'static str> = None;
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in updates {
let feature_key = feature.key();
let mut feature_edits = Vec::new();
if feature == Feature::GuardianApproval
&& !enabled
&& self.active_profile.is_some()
&& root_approvals_reviewer_blocks_profile_disable
{
self.chat_widget.add_error_message(
"Cannot disable Auto-review in this profile because `approvals_reviewer` is configured outside the active profile.".to_string(),
);
continue;
}
let mut feature_config = next_config.clone();
if let Err(err) = feature_config.features.set_enabled(feature, enabled) {
tracing::error!(
error = %err,
feature = feature_key,
"failed to update constrained feature flags"
);
self.chat_widget.add_error_message(format!(
"Failed to update experimental feature `{feature_key}`: {err}"
));
continue;
}
let effective_enabled = feature_config.features.enabled(feature);
if feature == Feature::GuardianApproval {
let previous_approvals_reviewer = feature_config.approvals_reviewer;
if effective_enabled {
// Persist the reviewer setting so future sessions keep the
// experiment's matching `/approvals` mode until the user
// changes it explicitly.
feature_config.approvals_reviewer =
guardian_approvals_preset.approvals_reviewer;
feature_edits.push(ConfigEdit::SetPath {
segments: scoped_segments("approvals_reviewer"),
value: guardian_approvals_preset
.approvals_reviewer
.to_string()
.into(),
});
if previous_approvals_reviewer != guardian_approvals_preset.approvals_reviewer {
permissions_history_label = Some("Auto-review");
}
} else if !effective_enabled {
if profile_approvals_reviewer_configured || self.active_profile.is_none() {
feature_edits.push(ConfigEdit::ClearPath {
segments: scoped_segments("approvals_reviewer"),
});
}
feature_config.approvals_reviewer = ApprovalsReviewer::User;
if previous_approvals_reviewer != ApprovalsReviewer::User {
permissions_history_label = Some("Default");
}
}
approvals_reviewer_override = Some(feature_config.approvals_reviewer);
}
if feature == Feature::GuardianApproval && effective_enabled {
// The feature flag alone is not enough for the live session.
// We also align approval policy + sandbox to the Auto-review
// preset so enabling the experiment immediately
// makes guardian review observable in the current thread.
if !self.try_set_approval_policy_on_config(
&mut feature_config,
guardian_approvals_preset.approval_policy,
"Failed to enable Auto-review",
"failed to set guardian approvals approval policy on staged config",
) {
continue;
}
if !self.try_set_sandbox_policy_on_config(
&mut feature_config,
guardian_approvals_preset.sandbox_policy.clone(),
"Failed to enable Auto-review",
"failed to set guardian approvals sandbox policy on staged config",
) {
continue;
}
feature_edits.extend([
ConfigEdit::SetPath {
segments: scoped_segments("approval_policy"),
value: "on-request".into(),
},
ConfigEdit::SetPath {
segments: scoped_segments("sandbox_mode"),
value: "workspace-write".into(),
},
]);
approval_policy_override = Some(guardian_approvals_preset.approval_policy);
sandbox_policy_override = Some(guardian_approvals_preset.sandbox_policy.clone());
}
next_config = feature_config;
feature_updates_to_apply.push((feature, effective_enabled));
builder = builder
.with_edits(feature_edits)
.set_feature_enabled(feature_key, effective_enabled);
}
// Persist first so the live session does not diverge from disk if the
// config edit fails. Runtime/UI state is patched below only after the
// durable config update succeeds.
if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget
.add_error_message(format!("Failed to update experimental features: {err}"));
return;
}
let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool);
self.config = next_config;
let show_memory_enable_notice =
feature_updates_to_apply.iter().any(|(feature, enabled)| {
*feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled
});
for (feature, effective_enabled) in feature_updates_to_apply {
self.chat_widget
.set_feature_enabled(feature, effective_enabled);
}
if show_memory_enable_notice {
self.chat_widget.add_memories_enable_notice();
}
if approvals_reviewer_override.is_some() {
self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer);
}
if approval_policy_override.is_some() {
self.chat_widget
.set_approval_policy(self.config.permissions.approval_policy.value());
}
if sandbox_policy_override.is_some()
&& let Err(err) = self
.chat_widget
.set_sandbox_policy(self.config.permissions.sandbox_policy.get().clone())
{
tracing::error!(
error = %err,
"failed to set guardian approvals sandbox policy on chat config"
);
self.chat_widget
.add_error_message(format!("Failed to enable Auto-review: {err}"));
}
if approval_policy_override.is_some()
|| approvals_reviewer_override.is_some()
|| sandbox_policy_override.is_some()
{
// This uses `OverrideTurnContext` intentionally: toggling the
// experiment should update the active thread's effective approval
// settings immediately, just like a `/approvals` selection. Without
// this runtime patch, the config edit would only affect future
// sessions or turns recreated from disk.
let op = AppCommand::override_turn_context(
/*cwd*/ None,
approval_policy_override,
approvals_reviewer_override,
sandbox_policy_override,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
);
let replay_state_op =
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
let submitted = self.chat_widget.submit_op(op);
if submitted && let Some(op) = replay_state_op.as_ref() {
self.note_active_thread_outbound_op(op).await;
self.refresh_pending_thread_approvals().await;
}
}
if windows_sandbox_changed {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*sandbox_policy*/ None,
#[cfg(target_os = "windows")]
Some(windows_sandbox_level),
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)
.into_core(),
));
}
}
if let Some(label) = permissions_history_label {
self.chat_widget.add_info_message(
format!("Permissions updated to {label}"),
/*hint*/ None,
);
}
}
pub(super) async fn update_memory_settings(
&mut self,
use_memories: bool,
generate_memories: bool,
) -> bool {
let active_profile = self.active_profile.clone();
let scoped_memory_segments = |key: &str| {
if let Some(profile) = active_profile.as_deref() {
vec![
"profiles".to_string(),
profile.to_string(),
"memories".to_string(),
key.to_string(),
]
} else {
vec!["memories".to_string(), key.to_string()]
}
};
let edits = [
ConfigEdit::SetPath {
segments: scoped_memory_segments("use_memories"),
value: use_memories.into(),
},
ConfigEdit::SetPath {
segments: scoped_memory_segments("generate_memories"),
value: generate_memories.into(),
},
];
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
tracing::error!(error = %err, "failed to persist memory settings");
self.chat_widget
.add_error_message(format!("Failed to save memory settings: {err}"));
return false;
}
self.config.memories.use_memories = use_memories;
self.config.memories.generate_memories = generate_memories;
self.chat_widget
.set_memory_settings(use_memories, generate_memories);
true
}
pub(super) async fn update_memory_settings_with_app_server(
&mut self,
app_server: &mut AppServerSession,
use_memories: bool,
generate_memories: bool,
) {
let previous_generate_memories = self.config.memories.generate_memories;
if !self
.update_memory_settings(use_memories, generate_memories)
.await
{
return;
}
if previous_generate_memories == generate_memories {
return;
}
let Some(thread_id) = self.current_displayed_thread_id() else {
return;
};
let mode = if generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
};
if let Err(err) = app_server.thread_memory_mode_set(thread_id, mode).await {
tracing::error!(error = %err, %thread_id, "failed to update thread memory mode");
self.chat_widget.add_error_message(format!(
"Saved memory settings, but failed to update the current thread: {err}"
));
}
}
pub(super) async fn reset_memories_with_app_server(
&mut self,
app_server: &mut AppServerSession,
) {
if let Err(err) = app_server.memory_reset().await {
tracing::error!(error = %err, "failed to reset memories");
self.chat_widget
.add_error_message(format!("Failed to reset memories: {err}"));
return;
}
self.chat_widget
.add_info_message("Reset local memories.".to_string(), /*hint*/ None);
}
pub(super) fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {
match reasoning_effort {
Some(ReasoningEffortConfig::Minimal) => "minimal",
Some(ReasoningEffortConfig::Low) => "low",
Some(ReasoningEffortConfig::Medium) => "medium",
Some(ReasoningEffortConfig::High) => "high",
Some(ReasoningEffortConfig::XHigh) => "xhigh",
None | Some(ReasoningEffortConfig::None) => "default",
}
}
pub(super) fn reasoning_label_for(
model: &str,
reasoning_effort: Option<ReasoningEffortConfig>,
) -> Option<&'static str> {
(!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort))
}
pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage {
self.chat_widget.token_usage()
}
pub(super) fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
// TODO(aibrahim): Remove this and don't use config as a state object.
// Instead, explicitly pass the stored collaboration mode's effort into new sessions.
self.config.model_reasoning_effort = effort;
self.chat_widget.set_reasoning_effort(effort);
}
pub(super) fn on_update_personality(&mut self, personality: Personality) {
self.config.personality = Some(personality);
self.chat_widget.set_personality(personality);
}
pub(super) fn sync_tui_theme_selection(&mut self, name: String) {
self.config.tui_theme = Some(name.clone());
self.chat_widget.set_tui_theme(Some(name));
}
pub(super) fn restore_runtime_theme_from_config(&self) {
if let Some(name) = self.config.tui_theme.as_deref()
&& let Some(theme) =
crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home))
{
crate::render::highlight::set_syntax_theme(theme);
return;
}
let auto_theme_name = crate::render::highlight::adaptive_default_theme_name();
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
auto_theme_name,
Some(&self.config.codex_home),
) {
crate::render::highlight::set_syntax_theme(theme);
}
}
pub(super) fn personality_label(personality: Personality) -> &'static str {
match personality {
Personality::None => "None",
Personality::Friendly => "Friendly",
Personality::Pragmatic => "Pragmatic",
}
}
}
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
//! Terminal history and clear-screen UI helpers for the TUI app.
//!
//! This module owns rendering the fresh session header, clearing inline or alternate-screen UI
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L.
use super::*;
impl App {
pub(super) fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
.add_error_message(format!("Failed to open browser for {url}: {err}"));
return;
}
self.chat_widget
.add_info_message(format!("Opened {url} in your browser."), /*hint*/ None);
}
pub(super) fn clear_ui_header_lines_with_version(
&self,
width: u16,
version: &'static str,
) -> Vec<Line<'static>> {
history_cell::SessionHeaderHistoryCell::new(
self.chat_widget.current_model().to_string(),
self.chat_widget.current_reasoning_effort(),
self.chat_widget.should_show_fast_status(
self.chat_widget.current_model(),
self.chat_widget.current_service_tier(),
),
self.config.cwd.to_path_buf(),
version,
)
.with_yolo_mode(history_cell::is_yolo_mode(&self.config))
.display_lines(width)
}
pub(super) fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION)
}
pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) {
let width = tui.terminal.last_known_screen_size.width;
let header_lines = self.clear_ui_header_lines(width);
if !header_lines.is_empty() {
tui.insert_history_lines(header_lines);
self.has_emitted_history_lines = true;
}
}
pub(super) fn clear_terminal_ui(
&mut self,
tui: &mut tui::Tui,
redraw_header: bool,
) -> Result<()> {
let is_alt_screen_active = tui.is_alt_screen_active();
// Drop queued history insertions so stale transcript lines cannot be flushed after /clear.
tui.clear_pending_history_lines();
if is_alt_screen_active {
tui.terminal.clear_visible_screen()?;
} else {
// Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and
// clear are emitted as separate backend commands. Prefer a single ANSI sequence.
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
let mut area = tui.terminal.viewport_area;
if area.y > 0 {
// After a full clear, anchor the inline viewport at the top and redraw a fresh header
// box. `insert_history_lines()` will shift the viewport down by the rendered height.
area.y = 0;
tui.terminal.set_viewport_area(area);
}
self.has_emitted_history_lines = false;
if redraw_header {
self.queue_clear_ui_header(tui);
}
Ok(())
}
pub(super) fn reset_app_ui_state_after_clear(&mut self) {
self.overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Keyboard input, external editor, and status-line dispatch for the TUI app.
//!
//! This module owns global key bindings that sit above ChatWidget, including transcript overlay
//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts.
use super::*;
impl App {
pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
let editor_cmd = match external_editor::resolve_editor_command() {
Ok(cmd) => cmd,
Err(external_editor::EditorError::MissingEditor) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(
"Cannot open external editor: set $VISUAL or $EDITOR before starting Codex."
.to_string(),
));
self.reset_external_editor_state(tui);
return;
}
Err(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to open editor: {err}",
)));
self.reset_external_editor_state(tui);
return;
}
};
let seed = self.chat_widget.composer_text_with_pending();
let editor_result = tui
.with_restored(tui::RestoreMode::KeepRaw, || async {
external_editor::run_editor(&seed, &editor_cmd).await
})
.await;
self.reset_external_editor_state(tui);
match editor_result {
Ok(new_text) => {
// Trim trailing whitespace
let cleaned = new_text.trim_end().to_string();
self.chat_widget.apply_external_edit(cleaned);
}
Err(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to open editor: {err}",
)));
}
}
tui.frame_requester().schedule_frame();
}
pub(super) fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Requested);
self.chat_widget.set_footer_hint_override(Some(vec![(
EXTERNAL_EDITOR_HINT.to_string(),
String::new(),
)]));
tui.frame_requester().schedule_frame();
}
pub(super) fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Closed);
self.chat_widget.set_footer_hint_override(/*items*/ None);
tui.frame_requester().schedule_frame();
}
pub(super) async fn handle_key_event(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
key_event: KeyEvent,
) {
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
// agent-switch shortcuts when the composer is empty so we never steal the expected
// editing behavior for moving across words inside a draft.
let allow_agent_word_motion_fallback = !self.enhanced_keys_supported
&& self.chat_widget.composer_text_with_pending().is_empty();
if self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
// Alt+Left/Right are also natural word-motion keys in the composer. Keep agent
// fast-switch available only once the draft is empty so editing behavior wins whenever
// there is text on screen.
&& self.chat_widget.composer_text_with_pending().is_empty()
&& previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
{
if let Some(thread_id) = self
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous)
.await
{
let _ = self
.select_agent_thread_and_discard_side(tui, app_server, thread_id)
.await;
}
return;
}
if self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
// Mirror the previous-agent rule above: empty drafts may use these keys for thread
// switching, but non-empty drafts keep them for expected word-wise cursor motion.
&& self.chat_widget.composer_text_with_pending().is_empty()
&& next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
{
if let Some(thread_id) = self
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next)
.await
{
let _ = self
.select_agent_thread_and_discard_side(tui, app_server, thread_id)
.await;
}
return;
}
if side_return_shortcut_matches(key_event)
&& self.maybe_return_from_side(tui, app_server).await
{
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
if !self.chat_widget.can_run_ctrl_l_clear_now() {
return;
}
if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) {
tracing::warn!(error = %err, "failed to clear terminal UI");
self.chat_widget
.add_error_message(format!("Failed to clear terminal UI: {err}"));
} else {
self.reset_app_ui_state_after_clear();
self.queue_clear_ui_header(tui);
tui.frame_requester().schedule_frame();
}
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
}
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty()
{
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} if self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty() =>
{
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
}
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
// Any non-Esc key press should cancel a primed backtrack.
// This avoids stale "Esc-primed" state after the user starts typing
// (even if they later backspace to empty).
if key_event.code != KeyCode::Esc && self.backtrack.primed {
self.reset_backtrack_state();
}
self.chat_widget.handle_key_event(key_event);
}
_ => {
self.chat_widget.handle_key_event(key_event);
}
};
}
pub(super) fn refresh_status_line(&mut self) {
self.chat_widget.refresh_status_line();
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Platform-specific app actions and small global shortcuts.
//!
//! This module owns platform state used by `App`, the side-conversation return shortcut predicate,
//! and Windows sandbox helper actions that are compiled only on Windows.
use super::*;
#[derive(Default)]
pub(super) struct WindowsSandboxState {
pub(super) setup_started_at: Option<Instant>,
// One-shot suppression of the next world-writable scan after user confirmation.
pub(super) skip_world_writable_scan_once: bool,
}
impl App {
#[cfg(target_os = "windows")]
pub(super) fn spawn_world_writable_scan(
cwd: AbsolutePathBuf,
env_map: std::collections::HashMap<String, String>,
logs_base_dir: AbsolutePathBuf,
sandbox_policy: codex_protocol::protocol::SandboxPolicy,
tx: AppEventSender,
) {
tokio::task::spawn_blocking(move || {
let logs_base_dir_path = logs_base_dir.as_path();
let result = codex_windows_sandbox::apply_world_writable_scan_and_denies(
logs_base_dir_path,
cwd.as_path(),
&env_map,
&sandbox_policy,
Some(logs_base_dir_path),
);
if result.is_err() {
// Scan failed: warn without examples.
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths: Vec::new(),
extra_count: 0usize,
failed_scan: true,
});
}
});
}
}
pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool {
match key_event {
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => true,
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => true,
_ => false,
}
}
+732
View File
@@ -0,0 +1,732 @@
//! Session, resume, fork, and subagent selection lifecycle for the TUI app.
//!
//! This module owns the high-level transitions between app-server threads: starting fresh sessions,
//! resuming/forking saved sessions, replacing ChatWidget instances, and maintaining the agent picker
//! cache used for multi-agent navigation.
use super::*;
impl App {
pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) {
let mut thread_ids = self.agent_navigation.tracked_thread_ids();
for thread_id in self.thread_event_channels.keys().copied() {
if !thread_ids.contains(&thread_id) {
thread_ids.push(thread_id);
}
}
for thread_id in thread_ids {
if self.side_threads.contains_key(&thread_id) {
continue;
}
if !self
.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await
{
continue;
}
}
let has_non_primary_agent_thread = self
.agent_navigation
.has_non_primary_thread(self.primary_thread_id);
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
self.chat_widget.open_multi_agent_enable_prompt();
return;
}
if self.agent_navigation.is_empty() {
self.chat_widget
.add_info_message("No agents available yet.".to_string(), /*hint*/ None);
return;
}
let mut initial_selected_idx = None;
let items: Vec<SelectionItem> = self
.agent_navigation
.ordered_threads()
.iter()
.enumerate()
.map(|(idx, (thread_id, entry))| {
if self.active_thread_id == Some(*thread_id) {
initial_selected_idx = Some(idx);
}
let id = *thread_id;
let is_primary = self.primary_thread_id == Some(*thread_id);
let name = format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
);
let uuid = thread_id.to_string();
SelectionItem {
name: name.clone(),
name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed),
description: Some(uuid.clone()),
is_current: self.active_thread_id == Some(*thread_id),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SelectAgentThread(id));
})],
dismiss_on_select: true,
search_value: Some(format!("{name} {uuid}")),
..Default::default()
}
})
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Subagents".to_string()),
subtitle: Some(AgentNavigationState::picker_subtitle()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
});
}
pub(super) fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("thread not loaded:"))
}
pub(super) fn closed_state_for_thread_read_error(
err: &color_eyre::Report,
existing_is_closed: Option<bool>,
) -> bool {
Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false)
}
pub(super) fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool {
err.chain().any(|cause| {
let message = cause.to_string();
message.contains("includeTurns is unavailable before first user message")
|| message.contains("ephemeral threads do not support includeTurns")
})
}
/// Updates cached picker metadata and then mirrors any visible-label change into the footer.
///
/// These two writes stay paired so the picker rows and contextual footer continue to describe
/// the same displayed thread after nickname or role updates.
pub(super) fn upsert_agent_picker_thread(
&mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
agent_role: Option<String>,
is_closed: bool,
) {
self.chat_widget.set_collab_agent_metadata(
thread_id,
agent_nickname.clone(),
agent_role.clone(),
);
self.agent_navigation
.upsert(thread_id, agent_nickname, agent_role, is_closed);
self.sync_active_agent_label();
}
/// Marks a cached picker thread closed and recomputes the contextual footer label.
///
/// Closing a thread is not the same as removing it: users can still inspect finished agent
/// transcripts, and the stable next/previous traversal order should not collapse around them.
pub(super) fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) {
self.agent_navigation.mark_closed(thread_id);
self.sync_active_agent_label();
}
pub(super) async fn refresh_agent_picker_thread_liveness(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> bool {
let existing_entry = self.agent_navigation.get(&thread_id).cloned();
let has_replay_channel = self.thread_event_channels.contains_key(&thread_id);
match app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
{
Ok(thread) => {
self.upsert_agent_picker_thread(
thread_id,
thread.agent_nickname.or_else(|| {
existing_entry
.as_ref()
.and_then(|entry| entry.agent_nickname.clone())
}),
thread.agent_role.or_else(|| {
existing_entry
.as_ref()
.and_then(|entry| entry.agent_role.clone())
}),
matches!(
thread.status,
codex_app_server_protocol::ThreadStatus::NotLoaded
),
);
true
}
Err(err) => {
if Self::is_terminal_thread_read_error(&err) && !has_replay_channel {
self.agent_navigation.remove(thread_id);
return false;
}
let is_closed = Self::closed_state_for_thread_read_error(
&err,
existing_entry.as_ref().map(|entry| entry.is_closed),
);
if let Some(entry) = existing_entry {
self.upsert_agent_picker_thread(
thread_id,
entry.agent_nickname,
entry.agent_role,
is_closed,
);
} else {
self.upsert_agent_picker_thread(
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
is_closed,
);
}
true
}
}
}
/// Materializes a live thread into local replay state when the picker knows about it but the
/// TUI has not cached a local event channel yet.
///
/// Resume-time backfill intentionally avoids creating empty placeholder channels, because those
/// placeholders make stale `/agent` entries open blank transcripts. When a user later selects a
/// still-live discovered thread, attach it on demand with a real resumed snapshot.
pub(super) async fn attach_live_thread_for_selection(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<bool> {
if self.thread_event_channels.contains_key(&thread_id) {
return Ok(true);
}
let (session, turns, live_attached) = match app_server
.resume_thread(self.config.clone(), thread_id)
.await
{
Ok(started) => (started.session, started.turns, true),
Err(resume_err) => {
tracing::warn!(
thread_id = %thread_id,
error = %resume_err,
"failed to resume live thread for selection; falling back to thread/read"
);
let (thread, turns) = match app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
{
Ok(thread) => {
let turns = thread.turns.clone();
(thread, turns)
}
Err(err) if Self::can_fallback_from_include_turns_error(&err) => {
let thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await?;
(thread, Vec::new())
}
Err(err) => return Err(err),
};
if turns.is_empty() {
// A `thread/read` fallback without turns would create a blank local replay
// channel with no live listener attached, which blocks later real re-attach.
return Err(color_eyre::eyre::eyre!(
"Agent thread {thread_id} is not yet available for replay or live attach."
));
}
let mut session = self.session_state_for_thread_read(thread_id, &thread).await;
// `thread/read` can seed replay state, but it does not attach the app-server
// listener that `thread/resume` establishes, so treat this path as replay-only.
session.model.clear();
(session, turns, false)
}
};
let channel = self.ensure_thread_channel(thread_id);
let mut store = channel.store.lock().await;
store.set_session(session, turns);
Ok(live_attached)
}
/// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation
/// cache.
///
/// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map.
/// This helper copies every known nickname/role from `AgentNavigationState` into the
/// replacement widget so that replayed collab items render agent names immediately.
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
// Transfer the last-written terminal title to the replacement widget
// so it knows what OSC title is currently displayed. Without this, the
// new widget would redundantly clear and rewrite the same title, causing
// a visible flicker in some terminals.
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
if chat_widget.last_terminal_title.is_none() {
chat_widget.last_terminal_title = previous_terminal_title;
}
for (thread_id, entry) in self.agent_navigation.ordered_threads() {
chat_widget.set_collab_agent_metadata(
thread_id,
entry.agent_nickname.clone(),
entry.agent_role.clone(),
);
}
self.chat_widget = chat_widget;
self.sync_active_agent_label();
}
pub(super) async fn select_agent_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<()> {
if self.active_thread_id == Some(thread_id) {
return Ok(());
}
if !self
.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await
{
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let mut is_replay_only = self
.agent_navigation
.get(&thread_id)
.is_some_and(|entry| entry.is_closed);
let mut attached_replay_only = false;
if self.should_attach_live_thread_for_selection(thread_id) {
match self
.attach_live_thread_for_selection(app_server, thread_id)
.await
{
Ok(live_attached) => {
attached_replay_only = !live_attached;
if attached_replay_only {
is_replay_only = true;
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to agent thread {thread_id}: {err}"
));
return Ok(());
}
}
} else if !self.thread_event_channels.contains_key(&thread_id) && is_replay_only {
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let previous_thread_id = self.active_thread_id;
self.store_active_thread_receiver().await;
self.active_thread_id = None;
let Some((receiver, mut snapshot)) = self.activate_thread_for_replay(thread_id).await
else {
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is already active."));
if let Some(previous_thread_id) = previous_thread_id {
self.activate_thread_channel(previous_thread_id).await;
}
return Ok(());
};
self.refresh_snapshot_session_if_needed(
app_server,
thread_id,
is_replay_only,
&mut snapshot,
)
.await;
self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver);
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
/*initial_user_message*/ None,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot, !is_replay_only);
if is_replay_only {
let message = if attached_replay_only {
format!(
"Agent thread {thread_id} could not be resumed live. Replaying saved transcript."
)
} else {
format!("Agent thread {thread_id} is closed. Replaying saved transcript.")
};
self.chat_widget.add_info_message(message, /*hint*/ None);
}
self.drain_active_thread_events(tui).await?;
self.refresh_pending_thread_approvals().await;
Ok(())
}
pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool {
!self.thread_event_channels.contains_key(&thread_id)
&& self
.agent_navigation
.get(&thread_id)
.is_none_or(|entry| !entry.is_closed)
}
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
tui.clear_pending_history_lines();
self.has_emitted_history_lines = false;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
Ok(())
}
pub(super) fn clear_terminal_for_thread_switch<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
) -> Result<()>
where
B: Backend + Write,
{
terminal.clear_scrollback_and_visible_screen_ansi()?;
let mut area = terminal.viewport_area;
if area.y > 0 {
area.y = 0;
terminal.set_viewport_area(area);
}
Ok(())
}
pub(super) fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.thread_event_channels.clear();
self.agent_navigation.clear();
self.side_threads.clear();
self.active_thread_id = None;
self.active_thread_rx = None;
self.primary_thread_id = None;
self.last_subagent_backfill_attempt = None;
self.primary_session_configured = None;
self.pending_primary_events.clear();
self.pending_app_server_requests.clear();
self.chat_widget.set_pending_thread_approvals(Vec::new());
self.sync_active_agent_label();
}
pub(super) async fn start_fresh_session_with_summary_hint(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
session_start_source: Option<ThreadStartSource>,
initial_user_message: Option<crate::chatwidget::UserMessage>,
) {
// Start a fresh in-memory session while preserving resumability via persisted rollout
// history. If an initial message is provided, `enqueue_primary_thread_session` suppresses it
// until the new session is configured and any replayed turns have been rendered.
self.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
.await;
let model = self.chat_widget.current_model().to_string();
let config = self.fresh_session_config();
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
self.shutdown_current_thread(app_server).await;
let tracked_thread_ids: Vec<ThreadId> =
self.thread_event_channels.keys().copied().collect();
for thread_id in tracked_thread_ids {
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}");
}
}
self.config = config.clone();
match app_server
.start_thread_with_session_start_source(&config, session_start_source)
.await
{
Ok(started) => {
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
started,
initial_user_message,
)
.await
{
self.chat_widget.add_error_message(format!(
"Failed to attach to fresh app-server thread: {err}"
));
} else if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to start a fresh session through the app server: {err}"
));
self.config.model = Some(model);
}
}
tui.frame_requester().schedule_frame();
}
pub(super) async fn replace_chat_widget_with_app_server_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
started: AppServerStartedThread,
initial_user_message: Option<crate::chatwidget::UserMessage>,
) -> Result<()> {
// Initial messages are for freshly attached primary threads only. Thread switches and
// resume/fork flows pass `None` so they cannot replay old history and then auto-submit a new
// user turn by accident.
self.reset_thread_event_state();
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
initial_user_message,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.enqueue_primary_thread_session(started.session, started.turns)
.await?;
self.backfill_loaded_subagent_threads(app_server).await;
Ok(())
}
/// Fetches all loaded threads from the app server and registers descendants of the primary
/// thread in the navigation cache and chat widget metadata.
///
/// Called after `replace_chat_widget_with_app_server_thread` during resume, fork, and new
/// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even
/// if the TUI did not witness the original spawn events.
///
/// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked
/// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via
/// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the
/// `ChatWidget` metadata map.
pub(super) async fn backfill_loaded_subagent_threads(
&mut self,
app_server: &mut AppServerSession,
) -> bool {
let Some(primary_thread_id) = self.primary_thread_id else {
return false;
};
let loaded_thread_ids = match app_server
.thread_loaded_list(ThreadLoadedListParams {
cursor: None,
limit: None,
})
.await
{
Ok(response) => response.data,
Err(err) => {
tracing::warn!(%err, "failed to list loaded threads for subagent backfill");
return false;
}
};
let mut threads = Vec::new();
let mut had_read_error = false;
for thread_id in loaded_thread_ids {
let Ok(thread_id) = ThreadId::from_string(&thread_id) else {
tracing::warn!("ignoring loaded thread with invalid id during subagent backfill");
continue;
};
if thread_id == primary_thread_id {
continue;
}
match app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
{
Ok(thread) => threads.push(thread),
Err(err) => {
had_read_error = true;
tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread");
}
}
}
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
self.upsert_agent_picker_thread(
thread.thread_id,
thread.agent_nickname,
thread.agent_role,
/*is_closed*/ false,
);
}
!had_read_error
}
/// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the
/// local cache has no neighbor.
///
/// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no
/// adjacent entry exists, typically because the cache was never populated with remote
/// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the
/// first next/previous keypress in a resumed remote session discovers subagents on demand
/// without requiring the user to wait for a proactive fetch.
pub(super) async fn adjacent_thread_id_with_backfill(
&mut self,
app_server: &mut AppServerSession,
direction: AgentNavigationDirection,
) -> Option<ThreadId> {
let current_thread = self.current_displayed_thread_id();
if let Some(thread_id) = self
.agent_navigation
.adjacent_thread_id(current_thread, direction)
{
return Some(thread_id);
}
let primary_thread_id = self.primary_thread_id?;
if self.last_subagent_backfill_attempt == Some(primary_thread_id) {
return None;
}
if self.backfill_loaded_subagent_threads(app_server).await {
self.last_subagent_backfill_attempt = Some(primary_thread_id);
}
self.agent_navigation
.adjacent_thread_id(self.current_displayed_thread_id(), direction)
}
pub(super) fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.current_service_tier();
config
}
pub(super) async fn resume_target_session(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
target_session: SessionTarget,
) -> Result<AppRunControl> {
if self.ignore_same_thread_resume(&target_session) {
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
let current_cwd = self.config.cwd.to_path_buf();
let resume_cwd = if self.remote_app_server_url.is_some() {
current_cwd.clone()
} else {
match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
target_session.path.as_deref(),
CwdPromptAction::Resume,
/*allow_prompt*/ true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
}
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
match app_server
.resume_thread(resume_config.clone(), target_session.thread_id)
.await
{
Ok(resumed) => {
self.shutdown_current_thread(app_server).await;
self.config = resume_config;
tui.set_notification_settings(
self.config.tui_notifications.method,
self.config.tui_notifications.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
match self
.replace_chat_widget_with_app_server_thread(
tui, app_server, resumed, /*initial_user_message*/ None,
)
.await
{
Ok(()) => {
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_command {
let spans =
vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
}
}
Err(err) => {
let path_display = target_session.display_label();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
Ok(AppRunControl::Continue)
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Startup warnings, model migration prompts, and bootstrap prompt helpers.
//!
//! These helpers run before or during `App::run` bootstrap. They translate configuration and model
//! catalog state into one-time TUI prompts or warning cells without owning the main event loop.
use super::*;
pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) {
if errors.is_empty() {
return;
}
let error_count = errors.len();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!(
"Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
)),
)));
for error in errors {
let path = error.path.display();
let message = error.message.as_str();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!("{path}: {message}")),
)));
}
}
pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
for layer in config.config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
) {
let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else {
continue;
};
let Some(disabled_reason) = &layer.disabled_reason else {
continue;
};
disabled_folders.push((
dot_codex_folder.as_path().display().to_string(),
disabled_reason.clone(),
));
}
if disabled_folders.is_empty() {
return;
}
let mut message = concat!(
"Project-local config, hooks, and exec policies are disabled in the following folders ",
"until the project is trusted, but skills still load.\n",
)
.to_string();
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
let display_index = index + 1;
message.push_str(&format!(" {display_index}. {folder}\n"));
message.push_str(&format!(" {reason}\n"));
}
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(message),
)));
}
pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) {
let Some(message) =
crate::legacy_core::config::system_bwrap_warning(config.permissions.sandbox_policy.get())
else {
return;
};
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(message),
)));
}
pub(super) fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if !available_models
.iter()
.any(|preset| preset.model == target_model && preset.show_in_picker)
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
pub(super) fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
pub(super) fn target_preset_for_upgrade<'a>(
available_models: &'a [ModelPreset],
target_model: &str,
) -> Option<&'a ModelPreset> {
available_models
.iter()
.find(|preset| preset.model == target_model && preset.show_in_picker)
}
pub(super) const MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT: u32 = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct StartupTooltipOverride {
pub(super) model_slug: String,
pub(super) message: String,
}
pub(super) fn select_model_availability_nux(
available_models: &[ModelPreset],
nux_config: &ModelAvailabilityNuxConfig,
) -> Option<StartupTooltipOverride> {
available_models.iter().find_map(|preset| {
let ModelAvailabilityNux { message } = preset.availability_nux.as_ref()?;
let shown_count = nux_config
.shown_count
.get(&preset.model)
.copied()
.unwrap_or_default();
(shown_count < MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT).then(|| StartupTooltipOverride {
model_slug: preset.model.clone(),
message: message.clone(),
})
})
}
pub(super) async fn prepare_startup_tooltip_override(
config: &mut Config,
available_models: &[ModelPreset],
is_first_run: bool,
) -> Option<String> {
if is_first_run || !config.show_tooltips {
return None;
}
let tooltip_override =
select_model_availability_nux(available_models, &config.model_availability_nux)?;
let shown_count = config
.model_availability_nux
.shown_count
.get(&tooltip_override.model_slug)
.copied()
.unwrap_or_default();
let next_count = shown_count.saturating_add(1);
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
.set_model_availability_nux_count(&updated_shown_count)
.apply()
.await
{
tracing::error!(
error = %err,
model = %tooltip_override.model_slug,
"failed to persist model availability nux count"
);
return Some(tooltip_override.message);
}
config.model_availability_nux.shown_count = updated_shown_count;
Some(tooltip_override.message)
}
pub(super) async fn handle_model_migration_prompt_if_needed(
tui: &mut tui::Tui,
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
available_models: &[ModelPreset],
) -> Option<AppExitInfo> {
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref());
if let Some(ModelUpgrade {
id: target_model,
reasoning_effort_mapping,
migration_config_key,
model_link,
upgrade_copy,
migration_markdown,
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return None;
}
let target_model = target_model.to_string();
if !should_show_model_migration_prompt(
model,
&target_model,
&config.notices.model_migrations,
available_models,
) {
return None;
}
let current_preset = available_models.iter().find(|preset| preset.model == model);
let target_preset = target_preset_for_upgrade(available_models, &target_model);
let target_preset = target_preset?;
let target_display_name = target_preset.display_name.clone();
let heading_label = if target_display_name == model {
target_model.clone()
} else {
target_display_name.clone()
};
let target_description =
(!target_preset.description.is_empty()).then(|| target_preset.description.clone());
let can_opt_out = current_preset.is_some();
let prompt_copy = migration_copy_for_models(
model,
&target_model,
model_link.clone(),
upgrade_copy.clone(),
migration_markdown.clone(),
heading_label,
target_description,
can_opt_out,
);
match run_model_migration_prompt(tui, prompt_copy).await {
ModelMigrationOutcome::Accepted => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
&& let Some(reasoning_effort) = config.model_reasoning_effort
{
reasoning_effort_mapping
.get(&reasoning_effort)
.cloned()
.or(config.model_reasoning_effort)
} else {
config.model_reasoning_effort
};
config.model = Some(target_model.clone());
config.model_reasoning_effort = mapped_effort;
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
app_event_tx.send(AppEvent::PersistModelSelection {
model: target_model.clone(),
effort: mapped_effort,
});
}
ModelMigrationOutcome::Rejected => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
}
ModelMigrationOutcome::Exit => {
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
}
}
None
}
pub(super) fn normalize_harness_overrides_for_cwd(
mut overrides: ConfigOverrides,
base_cwd: &AbsolutePathBuf,
) -> Result<ConfigOverrides> {
if overrides.additional_writable_roots.is_empty() {
return Ok(overrides);
}
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
for root in overrides.additional_writable_roots.drain(..) {
let absolute = base_cwd.join(root);
normalized.push(absolute.into_path_buf());
}
overrides.additional_writable_roots = normalized;
Ok(overrides)
}
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
//! Thread event buffering and replay state for the TUI app.
//!
//! This module owns the per-thread event store used when the TUI switches between the main
//! conversation, subagents, and side conversations. It keeps buffered app-server notifications,
//! pending interactive request replay state, active-turn tracking, and saved composer state close
//! together with the replay behavior that consumes them.
use super::*;
#[derive(Debug, Clone)]
pub(super) struct ThreadEventSnapshot {
pub(super) session: Option<ThreadSessionState>,
pub(super) turns: Vec<Turn>,
pub(super) events: Vec<ThreadBufferedEvent>,
pub(super) input_state: Option<ThreadInputState>,
}
#[derive(Debug, Clone)]
pub(super) enum ThreadBufferedEvent {
Notification(ServerNotification),
Request(ServerRequest),
HistoryEntryResponse(GetHistoryEntryResponseEvent),
FeedbackSubmission(FeedbackThreadEvent),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FeedbackThreadEvent {
pub(super) category: FeedbackCategory,
pub(super) include_logs: bool,
pub(super) feedback_audience: FeedbackAudience,
pub(super) result: Result<String, String>,
}
#[derive(Debug)]
pub(super) struct ThreadEventStore {
pub(super) session: Option<ThreadSessionState>,
pub(super) turns: Vec<Turn>,
pub(super) buffer: VecDeque<ThreadBufferedEvent>,
pub(super) pending_interactive_replay: PendingInteractiveReplayState,
pub(super) active_turn_id: Option<String>,
pub(super) input_state: Option<ThreadInputState>,
pub(super) capacity: usize,
pub(super) active: bool,
}
impl ThreadEventStore {
pub(super) fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool {
matches!(
event,
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_))
| ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_))
| ThreadBufferedEvent::FeedbackSubmission(_)
)
}
pub(super) fn new(capacity: usize) -> Self {
Self {
session: None,
turns: Vec::new(),
buffer: VecDeque::new(),
pending_interactive_replay: PendingInteractiveReplayState::default(),
active_turn_id: None,
input_state: None,
capacity,
active: false,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn new_with_session(
capacity: usize,
session: ThreadSessionState,
turns: Vec<Turn>,
) -> Self {
let mut store = Self::new(capacity);
store.session = Some(session);
store.set_turns(turns);
store
}
pub(super) fn set_session(&mut self, session: ThreadSessionState, turns: Vec<Turn>) {
self.session = Some(session);
self.set_turns(turns);
}
pub(super) fn rebase_buffer_after_session_refresh(&mut self) {
self.buffer.retain(Self::event_survives_session_refresh);
}
pub(super) fn set_turns(&mut self, turns: Vec<Turn>) {
self.active_turn_id = turns
.iter()
.rev()
.find(|turn| matches!(turn.status, TurnStatus::InProgress))
.map(|turn| turn.id.clone());
self.turns = turns;
}
pub(super) fn push_notification(&mut self, notification: ServerNotification) {
self.pending_interactive_replay
.note_server_notification(&notification);
match &notification {
ServerNotification::TurnStarted(turn) => {
self.active_turn_id = Some(turn.turn.id.clone());
}
ServerNotification::TurnCompleted(turn) => {
if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) {
self.active_turn_id = None;
}
}
ServerNotification::ThreadClosed(_) => {
self.active_turn_id = None;
}
_ => {}
}
self.buffer
.push_back(ThreadBufferedEvent::Notification(notification));
if self.buffer.len() > self.capacity
&& let Some(removed) = self.buffer.pop_front()
&& let ThreadBufferedEvent::Request(request) = &removed
{
self.pending_interactive_replay
.note_evicted_server_request(request);
}
}
pub(super) fn push_request(&mut self, request: ServerRequest) {
self.pending_interactive_replay
.note_server_request(&request);
self.buffer.push_back(ThreadBufferedEvent::Request(request));
if self.buffer.len() > self.capacity
&& let Some(removed) = self.buffer.pop_front()
&& let ThreadBufferedEvent::Request(request) = &removed
{
self.pending_interactive_replay
.note_evicted_server_request(request);
}
}
pub(super) fn pending_replay_requests(&self) -> Vec<ServerRequest> {
self.buffer
.iter()
.filter_map(|event| match event {
ThreadBufferedEvent::Request(request)
if self
.pending_interactive_replay
.should_replay_snapshot_request(request) =>
{
Some(request.clone())
}
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(_)
| ThreadBufferedEvent::HistoryEntryResponse(_)
| ThreadBufferedEvent::FeedbackSubmission(_) => None,
})
.collect()
}
pub(super) fn apply_thread_rollback(&mut self, response: &ThreadRollbackResponse) {
self.turns = response.thread.turns.clone();
self.buffer.clear();
self.pending_interactive_replay = PendingInteractiveReplayState::default();
self.active_turn_id = None;
}
pub(super) fn snapshot(&self) -> ThreadEventSnapshot {
ThreadEventSnapshot {
session: self.session.clone(),
turns: self.turns.clone(),
// Thread switches replay buffered events into a rebuilt ChatWidget. Only replay
// interactive prompts that are still pending, or answered approvals/input will reappear.
events: self
.buffer
.iter()
.filter(|event| match event {
ThreadBufferedEvent::Request(request) => self
.pending_interactive_replay
.should_replay_snapshot_request(request),
ThreadBufferedEvent::Notification(_)
| ThreadBufferedEvent::HistoryEntryResponse(_)
| ThreadBufferedEvent::FeedbackSubmission(_) => true,
})
.cloned()
.collect(),
input_state: self.input_state.clone(),
}
}
pub(super) fn note_outbound_op<T>(&mut self, op: T)
where
T: Into<AppCommand>,
{
self.pending_interactive_replay.note_outbound_op(op);
}
pub(super) fn op_can_change_pending_replay_state<T>(op: T) -> bool
where
T: Into<AppCommand>,
{
PendingInteractiveReplayState::op_can_change_state(op)
}
pub(super) fn has_pending_thread_approvals(&self) -> bool {
self.pending_interactive_replay
.has_pending_thread_approvals()
}
pub(super) fn side_parent_pending_status(&self) -> Option<SideParentStatus> {
if self
.pending_interactive_replay
.has_pending_thread_user_input()
{
Some(SideParentStatus::NeedsInput)
} else if self
.pending_interactive_replay
.has_pending_thread_approvals()
{
Some(SideParentStatus::NeedsApproval)
} else {
None
}
}
pub(super) fn active_turn_id(&self) -> Option<&str> {
self.active_turn_id.as_deref()
}
pub(super) fn clear_active_turn_id(&mut self) {
self.active_turn_id = None;
}
}
#[derive(Debug)]
pub(super) struct ThreadEventChannel {
pub(super) sender: mpsc::Sender<ThreadBufferedEvent>,
pub(super) receiver: Option<mpsc::Receiver<ThreadBufferedEvent>>,
pub(super) store: Arc<Mutex<ThreadEventStore>>,
}
impl ThreadEventChannel {
pub(super) fn new(capacity: usize) -> Self {
let (sender, receiver) = mpsc::channel(capacity);
Self {
sender,
receiver: Some(receiver),
store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn new_with_session(
capacity: usize,
session: ThreadSessionState,
turns: Vec<Turn>,
) -> Self {
let (sender, receiver) = mpsc::channel(capacity);
Self {
sender,
receiver: Some(receiver),
store: Arc::new(Mutex::new(ThreadEventStore::new_with_session(
capacity, session, turns,
))),
}
}
}
File diff suppressed because it is too large Load Diff