From 29e7d976987106fb5dfcd73dc72d529dc2aea8ca Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 5 Jul 2026 21:22:59 -0300 Subject: [PATCH] feat(tui): own terminal browser lifecycle --- codex-rs/tui/src/app.rs | 20 + codex-rs/tui/src/app/config_persistence.rs | 13 + .../tui/src/app/conversation_lifecycle.rs | 2 +- codex-rs/tui/src/app/event_dispatch.rs | 96 +++++ codex-rs/tui/src/app/input.rs | 30 ++ codex-rs/tui/src/app/session_lifecycle.rs | 7 +- codex-rs/tui/src/app/side.rs | 13 +- codex-rs/tui/src/app/terminal_browser.rs | 346 ++++++++++++++++++ .../tui/src/app/terminal_browser_tests.rs | 42 +++ codex-rs/tui/src/app/test_support.rs | 3 + codex-rs/tui/src/app/tests.rs | 6 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_event.rs | 60 +++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 91 +++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 9 + .../src/terminal_browser/profile_approval.rs | 9 +- codex-rs/tui/src/terminal_browser/tests.rs | 20 +- 18 files changed, 756 insertions(+), 13 deletions(-) create mode 100644 codex-rs/tui/src/app/terminal_browser.rs create mode 100644 codex-rs/tui/src/app/terminal_browser_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a9705be35a..b491d864fa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -161,6 +161,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; #[cfg(target_os = "windows")] use codex_protocol::permissions::FileSystemSandboxKind; use codex_rollout::StateDbHandle; +use codex_terminal_browser::TerminalBrowser; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_permission_profile_for_active_permission_profile; @@ -226,6 +227,7 @@ mod resize_reflow; mod session_lifecycle; mod side; mod startup_prompts; +mod terminal_browser; mod thread_events; mod thread_goal_actions; mod thread_routing; @@ -531,6 +533,12 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, + /// Carbonyl runtime shared by the active conversation and the browser panel. + pub(crate) terminal_browser: Option>, + /// Conversation currently allowed to drive `terminal_browser`. + pub(crate) terminal_browser_owner_thread_id: Option, + /// Invalidates delayed browser-profile approvals when the owned runtime changes. + pub(crate) terminal_browser_generation: u64, pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, pub(crate) enhanced_keys_supported: bool, @@ -1048,6 +1056,9 @@ See the Codex keymap documentation for supported actions and examples." enhanced_keys_supported, keymap: runtime_keymap, overlay: None, + terminal_browser: None, + terminal_browser_owner_thread_id: None, + terminal_browser_generation: 0, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), @@ -1231,6 +1242,8 @@ See the Codex keymap documentation for supported actions and examples." } } }; + app.reset_terminal_browser_for_thread_change().await; + tui.set_raw_mouse_events(/*raw_mouse_events*/ false); if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } @@ -1290,6 +1303,7 @@ See the Codex keymap documentation for supported actions and examples." | TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) + | TuiEvent::MouseOther(_) | TuiEvent::Draw => {} } if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { @@ -1339,6 +1353,9 @@ See the Codex keymap documentation for supported actions and examples." TuiEvent::MousePrimary(event) => { self.handle_owned_screen_mouse_primary(tui, event); } + // Raw browser input is routed after the browser panel is integrated into the + // owned-screen frame. Other app surfaces intentionally ignore these events. + TuiEvent::MouseOther(_) => {} TuiEvent::FocusLost => {} TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { @@ -1420,6 +1437,9 @@ See the Codex keymap documentation for supported actions and examples." impl Drop for App { fn drop(&mut self) { + if let Some(browser) = self.terminal_browser.as_ref() { + browser.terminate(); + } if let Err(err) = self.chat_widget.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title on app drop"); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index f25c4e9011..2033554ef8 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -519,6 +519,13 @@ impl App { &feature_updates_to_apply, ) .await; + if feature_updates_to_apply + .iter() + .any(|(feature, _)| *feature == Feature::TerminalBrowser) + && !self.config.features.enabled(Feature::TerminalBrowser) + { + self.reset_terminal_browser_for_thread_change().await; + } if windows_sandbox_changed { self.propagate_windows_sandbox_turn_context(); } @@ -528,6 +535,9 @@ impl App { let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool); self.config = next_config; + let terminal_browser_disabled = feature_updates_to_apply + .iter() + .any(|(feature, enabled)| *feature == Feature::TerminalBrowser && !enabled); let show_memory_enable_notice = feature_updates_to_apply.iter().any(|(feature, enabled)| { *feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled @@ -538,6 +548,9 @@ impl App { .set_feature_enabled(feature, effective_enabled); }); } + if terminal_browser_disabled { + self.reset_terminal_browser_for_thread_change().await; + } if show_memory_enable_notice { self.chat_widget.add_memories_enable_notice(); } diff --git a/codex-rs/tui/src/app/conversation_lifecycle.rs b/codex-rs/tui/src/app/conversation_lifecycle.rs index 17f804b971..342d834d1a 100644 --- a/codex-rs/tui/src/app/conversation_lifecycle.rs +++ b/codex-rs/tui/src/app/conversation_lifecycle.rs @@ -73,7 +73,7 @@ impl App { /*hint*/ None, ); } - self.chat_widget.focus(PaneSlot::Side); + self.focus_installed_conversation_pane(PaneSlot::Side); tui.frame_requester().schedule_frame(); } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ad188b26e4..acdb465071 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -428,6 +428,102 @@ impl App { self.chat_widget.prepare_local_op_submission(&op); self.submit_active_thread_op(app_server, op).await?; } + AppEvent::ToggleTerminalBrowser => { + self.toggle_terminal_browser().await; + tui.frame_requester().schedule_frame(); + } + AppEvent::CloseTerminalBrowser => { + self.close_terminal_browser(); + } + AppEvent::DoctorTerminalBrowser => { + self.doctor_terminal_browser().await; + } + AppEvent::TerminalBrowserDoctorCompleted { healthy, summary } => { + if healthy { + self.chat_widget.add_info_message(summary, /*hint*/ None); + } else { + self.chat_widget.add_error_message(summary); + } + } + AppEvent::ManageTerminalBrowserProfile(command) => { + self.manage_terminal_browser_profile(command).await; + } + AppEvent::ApproveTerminalBrowserProfile(approval) => { + self.approve_terminal_browser_profile(approval).await; + } + AppEvent::ToggleTerminalBrowserControl => { + self.toggle_terminal_browser_control().await; + } + AppEvent::TerminalBrowserControlCompleted { error } => { + let active = self.terminal_browser_owned_by_current_thread() + && self + .terminal_browser + .as_ref() + .is_some_and(|browser| browser.is_human_control_active()); + tui.set_raw_mouse_events(/*raw_mouse_events*/ active); + if let Some(error) = error { + self.chat_widget + .add_error_message(format!("Browser control failed: {error}")); + } else { + let message = if active { + "You control the browser now. Press Ctrl+] to return control to Codex." + } else { + "Browser control returned to Codex; previous node handles were invalidated." + }; + self.chat_widget + .add_info_message(message.to_string(), /*hint*/ None); + } + tui.frame_requester().schedule_frame(); + } + AppEvent::TerminalBrowserClosed => { + let raw_mouse_events = self.terminal_browser_owned_by_current_thread() + && self + .terminal_browser + .as_ref() + .is_some_and(|browser| browser.is_human_control_active()); + tui.set_raw_mouse_events(raw_mouse_events); + tui.frame_requester().schedule_frame(); + } + AppEvent::TerminalBrowserUpdated => { + let raw_mouse_events = self.terminal_browser_owned_by_current_thread() + && self + .terminal_browser + .as_ref() + .is_some_and(|browser| browser.is_human_control_active()); + tui.set_raw_mouse_events(raw_mouse_events); + tui.frame_requester().schedule_frame(); + } + AppEvent::TerminalBrowserToolCompleted { + request_id, + response, + profile_approval, + } => { + match serde_json::to_value(response) { + Ok(result) => { + if let Err(err) = + app_server.resolve_server_request(request_id, result).await + { + self.chat_widget.add_error_message(format!( + "Failed to resolve terminal browser tool request: {err}" + )); + } + } + Err(err) => { + let message = + format!("Failed to serialize terminal browser response: {err}"); + self.chat_widget.add_error_message(message.clone()); + if let Err(reject_err) = self + .reject_app_server_request(app_server, request_id, message) + .await + { + tracing::warn!(%reject_err, "failed to reject terminal-browser request"); + } + } + } + if let Some(approval) = profile_approval { + self.show_terminal_browser_profile_approval(approval); + } + } AppEvent::ConversationOp { target, op } => { self.submit_thread_op(app_server, target.thread_id, op) .await?; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 8dddfe47c0..3f67528060 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -309,6 +309,16 @@ impl App { if self.chat_widget.focused_slot() == target { return true; } + self.focus_installed_conversation_pane(target) + } + + pub(super) fn focus_installed_conversation_pane(&mut self, target: PaneSlot) -> bool { + if self.chat_widget.by_slot(target).is_none() { + return false; + } + if self.chat_widget.focused_slot() != target { + self.reset_terminal_browser_for_focus_change(); + } self.chat_widget.focus(target) } @@ -444,6 +454,26 @@ mod tests { assert!(!app.backtrack.primed); } + #[tokio::test] + async fn pane_focus_change_detaches_the_previous_terminal_browser() { + let mut app = make_test_app().await; + install_side_pane(&mut app).await; + let browser = Arc::new(codex_terminal_browser::TerminalBrowser::discover()); + app.terminal_browser = Some(browser); + app.terminal_browser_owner_thread_id = Some(ThreadId::new()); + app.terminal_browser_generation = 7; + + assert!(app.handle_conversation_pane_focus_key(KeyEvent::new( + KeyCode::Char('2'), + KeyModifiers::ALT, + ))); + + assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side); + assert!(app.terminal_browser.is_none()); + assert_eq!(app.terminal_browser_owner_thread_id, None); + assert_eq!(app.terminal_browser_generation, 8); + } + #[cfg(target_os = "macos")] #[tokio::test] async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() { diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 0b16d0a666..6ceae0d790 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -429,7 +429,7 @@ impl App { ); self.replace_chat_widget(ChatWidget::new_with_app_event(init)); - self.reset_for_thread_switch(tui)?; + self.reset_for_thread_switch(tui).await?; self.replay_thread_snapshot(snapshot, !is_replay_only); if is_replay_only { let message = if attached_replay_only { @@ -455,7 +455,8 @@ impl App { .is_none_or(|entry| !entry.is_closed) } - pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + pub(super) async fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.reset_terminal_browser_for_thread_change().await; self.reset_app_ui_state_after_clear(tui); tui.clear_pending_history_lines(); if !self.has_owned_screen() { @@ -544,6 +545,7 @@ impl App { // 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.reset_terminal_browser_for_thread_change().await; self.refresh_in_memory_config_from_disk_best_effort("starting a new thread") .await; let model = self.chat_widget.current_model().to_string(); @@ -618,6 +620,7 @@ impl App { // 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_terminal_browser_for_thread_change().await; self.reset_thread_event_state(); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 7f1a0981dd..0e4a2f407c 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -393,6 +393,9 @@ impl App { } pub(super) async fn discard_thread_local_state(&mut self, thread_id: ThreadId) { + if self.terminal_browser_owner_thread_id == Some(thread_id) { + self.reset_terminal_browser_for_thread_change().await; + } let remove_side_pane = self.installed_side_thread_id() == Some(thread_id); if remove_side_pane || self.side_threads.contains_key(&thread_id) { self.retire_thread(thread_id); @@ -435,7 +438,7 @@ impl App { thread_id: ThreadId, ) { if self.installed_side_thread_id() == Some(thread_id) { - self.chat_widget.focus(PaneSlot::Side); + self.focus_installed_conversation_pane(PaneSlot::Side); tui.frame_requester().schedule_frame(); } } @@ -560,7 +563,7 @@ impl App { thread_id: ThreadId, ) -> Result<()> { if self.installed_side_thread_id() == Some(thread_id) { - self.chat_widget.focus(PaneSlot::Side); + self.focus_installed_conversation_pane(PaneSlot::Side); tui.frame_requester().schedule_frame(); return Ok(()); } @@ -569,7 +572,7 @@ impl App { .prepare_agent_thread_selection(app_server, thread_id) .await else { - self.chat_widget.focus(PaneSlot::Side); + self.focus_installed_conversation_pane(PaneSlot::Side); tui.frame_requester().schedule_frame(); return Ok(()); }; @@ -581,7 +584,7 @@ impl App { self.keep_side_thread_visible_after_cleanup_failure(tui, side_thread_id); return Ok(()); } - self.chat_widget.focus(PaneSlot::Parent); + self.focus_installed_conversation_pane(PaneSlot::Parent); self.apply_prepared_agent_thread_selection(tui, app_server, selection) .await?; self.surface_pending_inactive_thread_interactive_requests() @@ -698,7 +701,7 @@ impl App { .await; return Ok(AppRunControl::Continue); } - self.chat_widget.focus(PaneSlot::Side); + self.focus_installed_conversation_pane(PaneSlot::Side); self.sync_side_thread_ui(); if let Err(err) = app_server diff --git a/codex-rs/tui/src/app/terminal_browser.rs b/codex-rs/tui/src/app/terminal_browser.rs new file mode 100644 index 0000000000..88d1c0adb9 --- /dev/null +++ b/codex-rs/tui/src/app/terminal_browser.rs @@ -0,0 +1,346 @@ +//! App-owned lifecycle for the Carbonyl-backed terminal browser. +//! +//! Rendering and input adaptation live in `crate::terminal_browser`. This module keeps the +//! process, permission policy, and app-server tool surface bound to one displayed conversation. + +use std::sync::Arc; + +use codex_features::Feature; +use codex_protocol::ThreadId; +use codex_terminal_browser::BrowserLaunchContext; +use codex_terminal_browser::BrowserNetworkPolicy; +use codex_terminal_browser::BrowserStatus; +use codex_terminal_browser::TerminalBrowser; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::TerminalBrowserProfileApproval; +use crate::app_event::TerminalBrowserProfileCommand; +use crate::terminal_browser::profile_approval_view_params; + +impl App { + fn terminal_browser_network_policy(&self) -> BrowserNetworkPolicy { + let config = self.chat_widget.config_ref(); + if config.permissions.network_sandbox_policy().is_enabled() + && config.permissions.network.is_none() + { + BrowserNetworkPolicy::Direct + } else { + BrowserNetworkPolicy::Disabled + } + } + + fn terminal_browser_enabled(&self) -> bool { + self.chat_widget + .config_ref() + .features + .enabled(Feature::TerminalBrowser) + && !self.app_server_target.uses_remote_workspace() + } + + fn discover_terminal_browser(&self) -> Arc { + let config = self.chat_widget.config_ref(); + let codex_linux_sandbox_exe = config.codex_linux_sandbox_exe.as_ref().and_then(|path| { + match AbsolutePathBuf::from_absolute_path_checked(path) { + Ok(path) => Some(path), + Err(error) => { + tracing::warn!( + %error, + path = %path.display(), + "invalid Linux sandbox helper path for terminal browser" + ); + None + } + } + }); + Arc::new(TerminalBrowser::discover_with_launch_context( + BrowserLaunchContext { + codex_home: Some(config.codex_home.clone()), + workspace_root: Some(config.cwd.clone()), + codex_linux_sandbox_exe, + use_legacy_landlock: config.features.use_legacy_landlock(), + }, + )) + } + + async fn terminal_browser_for_thread( + &mut self, + owner_thread_id: ThreadId, + ) -> Option> { + if !self.terminal_browser_enabled() { + self.reset_terminal_browser_for_thread_change().await; + return None; + } + + if self.terminal_browser_owner_thread_id != Some(owner_thread_id) { + self.reset_terminal_browser_for_thread_change().await; + } + + if self.terminal_browser.is_none() { + let browser = self.discover_terminal_browser(); + let mut updates = browser.subscribe(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + while updates.changed().await.is_ok() { + app_event_tx.send(AppEvent::TerminalBrowserUpdated); + } + }); + self.terminal_browser_generation = + self.terminal_browser_generation.wrapping_add(/*rhs*/ 1); + self.terminal_browser = Some(browser); + self.terminal_browser_owner_thread_id = Some(owner_thread_id); + } + + let network_policy = self.terminal_browser_network_policy(); + let browser = self.terminal_browser.as_ref()?.clone(); + browser.set_network_policy(network_policy).await; + Some(browser) + } + + async fn terminal_browser_for_active_thread(&mut self) -> Option> { + let owner_thread_id = self.current_displayed_thread_id()?; + self.terminal_browser_for_thread(owner_thread_id).await + } + + pub(super) fn terminal_browser_request_matches_active_thread( + &self, + request_thread_id: &str, + ) -> bool { + terminal_browser_request_matches_thread( + self.current_displayed_thread_id(), + request_thread_id, + ) + } + + pub(crate) fn terminal_browser_owned_by_current_thread(&self) -> bool { + self.terminal_browser.is_some() + && self + .terminal_browser_owner_thread_id + .is_some_and(|owner| self.current_displayed_thread_id() == Some(owner)) + } + + pub(super) async fn terminal_browser_for_active_request( + &mut self, + ) -> Option> { + self.terminal_browser_for_active_thread().await + } + + pub(super) async fn reconcile_terminal_browser_network_policy(&mut self) { + let Some(browser) = self.terminal_browser.as_ref().cloned() else { + return; + }; + let network_policy = self.terminal_browser_network_policy(); + let network_disabled = matches!(network_policy, BrowserNetworkPolicy::Disabled); + browser.set_network_policy(network_policy).await; + if network_disabled { + browser.set_visibility(/*visible*/ false); + } + } + + pub(super) async fn toggle_terminal_browser(&mut self) { + let Some(browser) = self.terminal_browser_for_active_thread().await else { + self.chat_widget.add_info_message( + "Terminal browser is disabled for this session.".to_string(), + Some( + "Enable the Terminal browser experiment, then start a new session.".to_string(), + ), + ); + return; + }; + if !browser.is_available() { + let message = match browser.view().status { + BrowserStatus::Unavailable { reason } => reason, + _ => "Carbonyl could not be discovered on this machine.".to_string(), + }; + self.chat_widget + .add_error_message(format!("Terminal browser is unavailable: {message}")); + return; + } + + browser.set_visibility(!browser.view().visible); + self.app_event_tx.send(AppEvent::TerminalBrowserUpdated); + } + + pub(super) fn close_terminal_browser(&mut self) { + let Some(browser) = self.terminal_browser.as_ref().cloned() else { + self.chat_widget.add_info_message( + "Terminal browser is not enabled.".to_string(), + /*hint*/ None, + ); + return; + }; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + browser.close().await; + app_event_tx.send(AppEvent::TerminalBrowserClosed); + }); + } + + fn take_terminal_browser_for_thread_change(&mut self) -> Option> { + self.terminal_browser_owner_thread_id = None; + self.terminal_browser_generation = + self.terminal_browser_generation.wrapping_add(/*rhs*/ 1); + let browser = self.terminal_browser.take()?; + browser.set_visibility(/*visible*/ false); + Some(browser) + } + + pub(super) async fn reset_terminal_browser_for_thread_change(&mut self) { + let Some(browser) = self.take_terminal_browser_for_thread_change() else { + return; + }; + browser.close().await; + self.app_event_tx.send(AppEvent::TerminalBrowserClosed); + } + + pub(super) fn reset_terminal_browser_for_focus_change(&mut self) { + let Some(browser) = self.take_terminal_browser_for_thread_change() else { + return; + }; + let app_event_tx = self.app_event_tx.clone(); + app_event_tx.send(AppEvent::TerminalBrowserClosed); + tokio::spawn(async move { + browser.close().await; + }); + } + + pub(super) async fn doctor_terminal_browser(&mut self) { + let Some(browser) = self.terminal_browser_for_active_thread().await else { + self.chat_widget + .add_error_message("Terminal browser is not enabled.".to_string()); + return; + }; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let report = browser.doctor().await; + app_event_tx.send(AppEvent::TerminalBrowserDoctorCompleted { + healthy: report.healthy, + summary: report.summary, + }); + }); + } + + pub(super) async fn manage_terminal_browser_profile( + &mut self, + command: TerminalBrowserProfileCommand, + ) { + let Some(browser) = self.terminal_browser_for_active_thread().await else { + self.chat_widget + .add_error_message("Terminal browser is not enabled.".to_string()); + return; + }; + let result = match command { + TerminalBrowserProfileCommand::List => browser.profiles().map(|profiles| { + let selected = browser + .selected_profile() + .map_or_else(|| "ephemeral".to_string(), |name| format!("named `{name}`")); + format!( + "Terminal-browser profiles: {}. Current profile: {selected}.", + if profiles.is_empty() { + "none".to_string() + } else { + profiles.join(", ") + } + ) + }), + TerminalBrowserProfileCommand::Create(name) => browser + .create_profile(&name) + .await + .map(|()| format!("Created and selected browser profile `{name}`.")), + TerminalBrowserProfileCommand::Use(name) => browser + .select_profile(&name) + .await + .map(|()| format!("Selected browser profile `{name}`.")), + TerminalBrowserProfileCommand::Ephemeral => browser + .select_ephemeral_profile() + .await + .map(|()| "Selected a new ephemeral browser profile.".to_string()), + TerminalBrowserProfileCommand::Forget(name) => browser + .forget_profile(&name) + .await + .map(|()| format!("Permanently deleted browser profile `{name}`.")), + }; + match result { + Ok(message) => self.chat_widget.add_info_message(message, /*hint*/ None), + Err(error) => self + .chat_widget + .add_error_message(format!("Browser profile command failed: {error}")), + } + } + + pub(super) fn terminal_browser_profile_approval( + &self, + command: TerminalBrowserProfileCommand, + ) -> Option { + let thread_id = self.terminal_browser_owner_thread_id?; + self.terminal_browser_owned_by_current_thread() + .then_some(TerminalBrowserProfileApproval { + command, + thread_id, + generation: self.terminal_browser_generation, + }) + } + + fn terminal_browser_profile_approval_is_current( + &self, + approval: &TerminalBrowserProfileApproval, + ) -> bool { + self.terminal_browser.is_some() + && self.current_displayed_thread_id() == Some(approval.thread_id) + && self.terminal_browser_owner_thread_id == Some(approval.thread_id) + && self.terminal_browser_generation == approval.generation + } + + pub(super) fn show_terminal_browser_profile_approval( + &mut self, + approval: TerminalBrowserProfileApproval, + ) { + if !self.terminal_browser_profile_approval_is_current(&approval) { + return; + } + self.chat_widget + .show_selection_view(profile_approval_view_params(approval)); + } + + pub(super) async fn approve_terminal_browser_profile( + &mut self, + approval: TerminalBrowserProfileApproval, + ) { + if !self.terminal_browser_profile_approval_is_current(&approval) { + self.chat_widget.add_info_message( + "Browser profile request expired after the active conversation changed." + .to_string(), + /*hint*/ None, + ); + return; + } + self.manage_terminal_browser_profile(approval.command).await; + } + + pub(super) async fn toggle_terminal_browser_control(&mut self) { + let Some(browser) = self.terminal_browser_for_active_thread().await else { + self.chat_widget + .add_error_message("Terminal browser is not enabled.".to_string()); + return; + }; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = browser.toggle_human_control().await; + app_event_tx.send(AppEvent::TerminalBrowserControlCompleted { + error: result.err().map(|error| error.to_string()), + }); + }); + } +} + +fn terminal_browser_request_matches_thread( + active_thread_id: Option, + request_thread_id: &str, +) -> bool { + active_thread_id.is_some_and(|thread_id| thread_id.to_string() == request_thread_id) +} + +#[cfg(test)] +#[path = "terminal_browser_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/terminal_browser_tests.rs b/codex-rs/tui/src/app/terminal_browser_tests.rs new file mode 100644 index 0000000000..042672b31c --- /dev/null +++ b/codex-rs/tui/src/app/terminal_browser_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use crate::app::test_support::make_test_app; +use crate::app_event::PaneSlot; + +#[test] +fn terminal_browser_requests_require_the_displayed_thread() { + let displayed = ThreadId::new(); + let inactive = ThreadId::new(); + + assert!(terminal_browser_request_matches_thread( + Some(displayed), + &displayed.to_string(), + )); + assert!(!terminal_browser_request_matches_thread( + Some(displayed), + &inactive.to_string(), + )); + assert!(!terminal_browser_request_matches_thread( + /*active_thread_id*/ None, + &displayed.to_string(), + )); +} + +#[tokio::test] +async fn profile_approval_expires_when_the_browser_generation_changes() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.chat_widget + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .attach_thread(thread_id, /*receiver*/ None); + app.terminal_browser = Some(Arc::new(TerminalBrowser::discover())); + app.terminal_browser_owner_thread_id = Some(thread_id); + app.terminal_browser_generation = 3; + let approval = app + .terminal_browser_profile_approval(TerminalBrowserProfileCommand::Ephemeral) + .expect("current browser should produce an approval token"); + + assert!(app.terminal_browser_profile_approval_is_current(&approval)); + app.terminal_browser_generation += 1; + assert!(!app.terminal_browser_profile_approval_is_current(&approval)); +} diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index ce3eb76090..40a42b5744 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -45,6 +45,9 @@ pub(super) async fn make_test_app_with_channels() -> ( runtime_approval_policy_override: None, runtime_permission_profile_override: None, overlay: None, + terminal_browser: None, + terminal_browser_owner_thread_id: None, + terminal_browser_generation: 0, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, enhanced_keys_supported: false, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 19b64c34c1..a0c6e9778d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4552,6 +4552,9 @@ async fn make_test_app() -> App { runtime_approval_policy_override: None, runtime_permission_profile_override: None, overlay: None, + terminal_browser: None, + terminal_browser_owner_thread_id: None, + terminal_browser_generation: 0, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, enhanced_keys_supported: false, @@ -4619,6 +4622,9 @@ async fn make_test_app_with_channels() -> ( runtime_approval_policy_override: None, runtime_permission_profile_override: None, overlay: None, + terminal_browser: None, + terminal_browser_owner_thread_id: None, + terminal_browser_generation: 0, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, enhanced_keys_supported: false, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 58288e87e8..46e90d74fc 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -33,6 +33,7 @@ impl App { } pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) { + self.reconcile_terminal_browser_network_policy().await; let Some(active_thread_id) = self.chat_widget.active_thread_id else { return; }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 60ccd5f16c..41d0d908c1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -15,6 +15,7 @@ use std::sync::atomic::Ordering; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::DynamicToolCallResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::MarketplaceAddResponse; @@ -28,6 +29,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_connectors::AppInfo; @@ -55,6 +57,22 @@ use codex_protocol::openai_models::ReasoningEffort; use crate::history_cell::HistoryCell; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TerminalBrowserProfileCommand { + List, + Create(String), + Use(String), + Ephemeral, + Forget(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TerminalBrowserProfileApproval { + pub(crate) command: TerminalBrowserProfileCommand, + pub(crate) thread_id: ThreadId, + pub(crate) generation: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ThreadGoalSetMode { ConfirmIfExists, @@ -340,6 +358,48 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(AppCommand), + /// Show or hide the terminal-browser panel. + ToggleTerminalBrowser, + + /// Close the terminal-browser process without forgetting a named profile. + CloseTerminalBrowser, + + /// Validate the configured Carbonyl executable and report its compatibility. + DoctorTerminalBrowser, + + /// The asynchronous Carbonyl validation probe completed. + TerminalBrowserDoctorCompleted { + healthy: bool, + summary: String, + }, + + /// Apply a user-entered or user-approved named-profile command. + ManageTerminalBrowserProfile(TerminalBrowserProfileCommand), + + /// Apply a model-requested profile command after confirming it still targets this browser. + ApproveTerminalBrowserProfile(TerminalBrowserProfileApproval), + + /// Enter or leave exclusive keyboard-and-mouse browser control. + ToggleTerminalBrowserControl, + + /// A human-control transition completed. + TerminalBrowserControlCompleted { + error: Option, + }, + + /// The terminal-browser process finished closing. + TerminalBrowserClosed, + + /// The browser's terminal frame, status, or visibility changed. + TerminalBrowserUpdated, + + /// Complete an app-server dynamic-tool request after browser work finishes. + TerminalBrowserToolCompleted { + request_id: AppServerRequestId, + response: DynamicToolCallResponse, + profile_approval: Option, + }, + /// Restore an output-free interrupted turn into the composer and roll it back. RestoreCancelledTurn(UserMessage), diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 833cb07635..ab2822ceb9 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -252,6 +252,19 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::OpenDesktopThread { thread_id }); } + SlashCommand::Browser => { + if !self.config.features.enabled(Feature::TerminalBrowser) { + self.add_info_message( + "Terminal browser is disabled.".to_string(), + Some( + "Enable the Terminal browser experiment, then start a new session." + .to_string(), + ), + ); + return; + } + self.app_event_tx.send(AppEvent::ToggleTerminalBrowser); + } SlashCommand::Init => { const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); @@ -696,6 +709,83 @@ impl ChatWidget { "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), }, + SlashCommand::Browser => { + let parts = trimmed.split_whitespace().collect::>(); + match parts.as_slice() { + [command] if command.eq_ignore_ascii_case("close") => { + self.app_event_tx.send(AppEvent::CloseTerminalBrowser); + } + [command] if command.eq_ignore_ascii_case("doctor") => { + self.app_event_tx.send(AppEvent::DoctorTerminalBrowser); + } + [command] if command.eq_ignore_ascii_case("control") => { + self.app_event_tx + .send(AppEvent::ToggleTerminalBrowserControl); + } + [profile, command] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("list") => + { + self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile( + crate::app_event::TerminalBrowserProfileCommand::List, + )); + } + [profile, command] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("ephemeral") => + { + self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile( + crate::app_event::TerminalBrowserProfileCommand::Ephemeral, + )); + } + [profile, command, name] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("create") => + { + self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile( + crate::app_event::TerminalBrowserProfileCommand::Create( + (*name).to_string(), + ), + )); + } + [profile, command, name] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("use") => + { + self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile( + crate::app_event::TerminalBrowserProfileCommand::Use( + (*name).to_string(), + ), + )); + } + [profile, command, name, confirm] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("forget") + && *confirm == "--confirm" => + { + self.app_event_tx.send(AppEvent::ManageTerminalBrowserProfile( + crate::app_event::TerminalBrowserProfileCommand::Forget( + (*name).to_string(), + ), + )); + } + [profile, command, name] + if profile.eq_ignore_ascii_case("profile") + && command.eq_ignore_ascii_case("forget") => + { + self.add_info_message( + format!( + "Profile `{name}` was not deleted. Run `/browser profile forget {name} --confirm` to confirm permanent deletion." + ), + /*hint*/ None, + ); + } + _ => self.add_error_message( + "Usage: /browser [close|doctor|control|profile list|profile ephemeral|profile create |profile use |profile forget --confirm]" + .to_string(), + ), + } + } SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { "" => self.open_keymap_picker(), "debug" => { @@ -1091,6 +1181,7 @@ impl ChatWidget { | SlashCommand::Vim | SlashCommand::Diff | SlashCommand::App + | SlashCommand::Browser | SlashCommand::Rename | SlashCommand::TestApproval => QueueDrain::Continue, SlashCommand::Feedback diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 64fee0ff87..3eb89a5cf3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -177,6 +177,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_browser; mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 7630faaa84..2662cb2978 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -36,6 +36,7 @@ pub enum SlashCommand { Resume, Fork, App, + Browser, Init, Compact, Plan, @@ -96,6 +97,7 @@ impl SlashCommand { SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", SlashCommand::App => "continue this session in Codex Desktop", + SlashCommand::Browser => "show, hide, or control the terminal browser", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Copy => "copy last response as markdown", SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", @@ -173,6 +175,7 @@ impl SlashCommand { | SlashCommand::Summary | SlashCommand::Resume | SlashCommand::SandboxReadRoot + | SlashCommand::Browser ) } @@ -189,6 +192,7 @@ impl SlashCommand { | SlashCommand::Ide | SlashCommand::Sidebar | SlashCommand::Summary + | SlashCommand::Browser ) } @@ -231,6 +235,7 @@ impl SlashCommand { | SlashCommand::Ps | SlashCommand::Stop | SlashCommand::App + | SlashCommand::Browser | SlashCommand::Goal | SlashCommand::Mcp | SlashCommand::Apps @@ -257,6 +262,7 @@ impl SlashCommand { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), SlashCommand::Copy => !cfg!(target_os = "android"), SlashCommand::App => cfg!(any(target_os = "macos", target_os = "windows")), + SlashCommand::Browser => cfg!(any(target_os = "macos", target_os = "linux")), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, } @@ -304,6 +310,9 @@ mod tests { assert!(SlashCommand::Raw.available_in_side_conversation()); assert!(SlashCommand::Raw.supports_inline_args()); assert!(SlashCommand::App.available_during_task()); + assert!(SlashCommand::Browser.available_during_task()); + assert!(SlashCommand::Browser.available_in_side_conversation()); + assert!(SlashCommand::Browser.supports_inline_args()); } #[test] diff --git a/codex-rs/tui/src/terminal_browser/profile_approval.rs b/codex-rs/tui/src/terminal_browser/profile_approval.rs index 4ecc5b3a52..5b9ce53158 100644 --- a/codex-rs/tui/src/terminal_browser/profile_approval.rs +++ b/codex-rs/tui/src/terminal_browser/profile_approval.rs @@ -1,3 +1,4 @@ +use crate::app_event::TerminalBrowserProfileApproval; use crate::app_event::TerminalBrowserProfileCommand; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -37,9 +38,9 @@ fn valid_profile_name(name: &str) -> bool { } pub(crate) fn profile_approval_view_params( - command: TerminalBrowserProfileCommand, + approval: TerminalBrowserProfileApproval, ) -> SelectionViewParams { - let (subtitle, approve_name, approve_description) = match &command { + let (subtitle, approve_name, approve_description) = match &approval.command { TerminalBrowserProfileCommand::Create(name) => ( format!("The model wants to create and select browser profile `{name}`."), "Create and select profile", @@ -69,8 +70,8 @@ pub(crate) fn profile_approval_view_params( ), }; let approve_actions: Vec = vec![Box::new(move |tx| { - tx.send(crate::app_event::AppEvent::ManageTerminalBrowserProfile( - command.clone(), + tx.send(crate::app_event::AppEvent::ApproveTerminalBrowserProfile( + approval.clone(), )); })]; SelectionViewParams { diff --git a/codex-rs/tui/src/terminal_browser/tests.rs b/codex-rs/tui/src/terminal_browser/tests.rs index 9b863fa56b..c3f299b6fb 100644 --- a/codex-rs/tui/src/terminal_browser/tests.rs +++ b/codex-rs/tui/src/terminal_browser/tests.rs @@ -30,6 +30,7 @@ use ratatui::style::Stylize; use tokio::sync::mpsc::unbounded_channel; use crate::app_event::AppEvent; +use crate::app_event::TerminalBrowserProfileApproval; use crate::app_event::TerminalBrowserProfileCommand; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ListSelectionView; @@ -309,6 +310,19 @@ fn human_control_maps_keyboard_and_mouse_input() { }) ); + assert_eq!( + browser_key_input(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)), + Some(BrowserKeyInput { + key: "?".to_string(), + code: "Slash".to_string(), + text: Some("?".to_string()), + modifiers: BrowserInputModifiers { + shift: true, + ..Default::default() + }, + }) + ); + assert_eq!( browser_mouse_input( MouseEvent { @@ -381,7 +395,11 @@ fn terminal_browser_unavailable_panel_wraps_reason_snapshot() { fn terminal_browser_profile_forget_approval_snapshot() { let (tx, _rx) = unbounded_channel::(); let view = ListSelectionView::new( - profile_approval_view_params(TerminalBrowserProfileCommand::Forget("work".to_string())), + profile_approval_view_params(TerminalBrowserProfileApproval { + command: TerminalBrowserProfileCommand::Forget("work".to_string()), + thread_id: codex_protocol::ThreadId::new(), + generation: 1, + }), AppEventSender::new(tx), crate::keymap::RuntimeKeymap::defaults().list, );