feat(tui): own terminal browser lifecycle

This commit is contained in:
Felipe Coury
2026-07-06 10:24:40 -03:00
parent 4cb937a1e7
commit 29e7d97698
18 changed files with 756 additions and 13 deletions
+20
View File
@@ -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<Overlay>,
/// Carbonyl runtime shared by the active conversation and the browser panel.
pub(crate) terminal_browser: Option<Arc<TerminalBrowser>>,
/// Conversation currently allowed to drive `terminal_browser`.
pub(crate) terminal_browser_owner_thread_id: Option<ThreadId>,
/// Invalidates delayed browser-profile approvals when the owned runtime changes.
pub(crate) terminal_browser_generation: u64,
pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
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");
}
@@ -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();
}
@@ -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();
}
}
+96
View File
@@ -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?;
+30
View File
@@ -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() {
+5 -2
View File
@@ -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,
+8 -5
View File
@@ -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
+346
View File
@@ -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<TerminalBrowser> {
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<Arc<TerminalBrowser>> {
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<Arc<TerminalBrowser>> {
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<Arc<TerminalBrowser>> {
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<Arc<TerminalBrowser>> {
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<TerminalBrowserProfileApproval> {
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<ThreadId>,
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;
@@ -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));
}
+3
View File
@@ -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,
+6
View File
@@ -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,
@@ -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;
};
+60
View File
@@ -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<String>,
},
/// 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<TerminalBrowserProfileApproval>,
},
/// Restore an output-free interrupted turn into the composer and roll it back.
RestoreCancelledTurn(UserMessage),
@@ -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::<Vec<_>>();
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 <name>|profile use <name>|profile forget <name> --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
+1
View File
@@ -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;
+9
View File
@@ -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]
@@ -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<SelectionAction> = vec![Box::new(move |tx| {
tx.send(crate::app_event::AppEvent::ManageTerminalBrowserProfile(
command.clone(),
tx.send(crate::app_event::AppEvent::ApproveTerminalBrowserProfile(
approval.clone(),
));
})];
SelectionViewParams {
+19 -1
View File
@@ -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::<AppEvent>();
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,
);