mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
7b994100b3
commit
2af4f15479
+21
-11347
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¤t_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(¤t_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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¬ification);
|
||||
match ¬ification {
|
||||
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
Reference in New Issue
Block a user