From 31eea28d4cea97b4fe429fc40473d5570fa0d32b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 7 Jul 2026 14:47:15 -0300 Subject: [PATCH] feat(terminal-browser): add codex-owned chrome --- codex-rs/terminal-browser/src/actions.rs | 7 + codex-rs/terminal-browser/src/diagnostics.rs | 2 +- .../terminal-browser/src/diagnostics_tests.rs | 9 +- .../terminal-browser/src/human_control.rs | 8 +- .../terminal-browser/src/human_navigation.rs | 60 +++ codex-rs/terminal-browser/src/lib.rs | 2 + codex-rs/terminal-browser/src/runtime.rs | 1 + .../terminal-browser/src/runtime_tests.rs | 4 + .../src/terminal_browser_tests.rs | 19 +- .../terminal-browser/src/terminal_input.rs | 2 +- .../src/terminal_input_tests.rs | 12 + .../terminal-browser/src/tool_dispatch.rs | 4 +- codex-rs/tui/src/app.rs | 25 ++ codex-rs/tui/src/app/event_dispatch.rs | 9 + codex-rs/tui/src/app/owned_screen.rs | 10 +- codex-rs/tui/src/app/owned_screen_tests.rs | 3 + ...owser_preserves_narrow_overlay_chrome.snap | 4 +- ...en_browser_renders_in_wide_right_rail.snap | 4 +- codex-rs/tui/src/app/terminal_browser.rs | 101 +++++ codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/terminal_browser/chrome.rs | 352 ++++++++++++++++++ .../tui/src/terminal_browser/chrome_tests.rs | 73 ++++ codex-rs/tui/src/terminal_browser/mod.rs | 6 +- codex-rs/tui/src/terminal_browser/panel.rs | 43 +-- ..._browser_human_control_panel_snapshot.snap | 4 +- ...ests__terminal_browser_panel_snapshot.snap | 4 +- ...available_panel_wraps_reason_snapshot.snap | 4 +- 29 files changed, 721 insertions(+), 60 deletions(-) create mode 100644 codex-rs/terminal-browser/src/human_navigation.rs create mode 100644 codex-rs/tui/src/terminal_browser/chrome.rs create mode 100644 codex-rs/tui/src/terminal_browser/chrome_tests.rs diff --git a/codex-rs/terminal-browser/src/actions.rs b/codex-rs/terminal-browser/src/actions.rs index dc6c21309f..681f87183c 100644 --- a/codex-rs/terminal-browser/src/actions.rs +++ b/codex-rs/terminal-browser/src/actions.rs @@ -138,6 +138,13 @@ pub(crate) async fn dispatch_human_key(client: &CdpClient, input: &BrowserKeyInp Ok(()) } +pub(crate) async fn insert_human_text(client: &CdpClient, text: &str) -> Result<()> { + client + .call("Input.insertText", json!({ "text": text })) + .await?; + Ok(()) +} + pub(crate) fn bounded_snapshot_json(mut snapshot: Value) -> Result { truncate_string_field(&mut snapshot, "url", MAX_SNAPSHOT_URL_CHARS); truncate_string_field(&mut snapshot, "title", MAX_SNAPSHOT_TITLE_CHARS); diff --git a/codex-rs/terminal-browser/src/diagnostics.rs b/codex-rs/terminal-browser/src/diagnostics.rs index 775f8fa0c0..9f771fa3b7 100644 --- a/codex-rs/terminal-browser/src/diagnostics.rs +++ b/codex-rs/terminal-browser/src/diagnostics.rs @@ -12,7 +12,7 @@ use crate::sandbox::BrowserLaunchContext; use crate::sandbox::prepare_browser_launch; const VERSION_TIMEOUT: Duration = Duration::from_secs(3); -const SUPPORTED_VERSION: &str = "0.0.3"; +const SUPPORTED_VERSION: &str = "0.0.3-codex.1"; const MAX_VERSION_OUTPUT_CHARS: usize = 256; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/terminal-browser/src/diagnostics_tests.rs b/codex-rs/terminal-browser/src/diagnostics_tests.rs index 9446a118b5..d9733b748a 100644 --- a/codex-rs/terminal-browser/src/diagnostics_tests.rs +++ b/codex-rs/terminal-browser/src/diagnostics_tests.rs @@ -8,14 +8,15 @@ use super::inspect_installation; #[test] fn version_probe_uses_the_first_nonempty_line() { assert_eq!( - first_nonempty_line(b"\n Carbonyl 0.0.3 \nextra\n"), - Some("Carbonyl 0.0.3".to_string()) + first_nonempty_line(b"\n Carbonyl 0.0.3-codex.1 \nextra\n"), + Some("Carbonyl 0.0.3-codex.1".to_string()) ); } #[test] fn version_probe_requires_the_exact_pinned_version() { - assert!(has_supported_version("Carbonyl 0.0.3")); + assert!(has_supported_version("Carbonyl 0.0.3-codex.1")); + assert!(!has_supported_version("Carbonyl 0.0.3")); assert!(!has_supported_version("Carbonyl 0.0.30")); assert!(!has_supported_version("Carbonyl 1.0.0")); } @@ -32,5 +33,5 @@ async fn configured_real_carbonyl_passes_the_sandboxed_version_probe() { .await .expect("supported installation"); - assert_eq!(installation.version, "Carbonyl 0.0.3"); + assert_eq!(installation.version, "Carbonyl 0.0.3-codex.1"); } diff --git a/codex-rs/terminal-browser/src/human_control.rs b/codex-rs/terminal-browser/src/human_control.rs index f8e3e98dc9..5afd5751bb 100644 --- a/codex-rs/terminal-browser/src/human_control.rs +++ b/codex-rs/terminal-browser/src/human_control.rs @@ -453,11 +453,9 @@ async fn run_human_input_worker(inner: Weak, mut receivers: HumanInputRec Some(bytes) => (send_terminal_input(writer, bytes).await, None, true), None => (actions::dispatch_human_key(&cdp, &input).await, None, true), }, - HumanInput::Text(text) => ( - send_terminal_input(writer, text.into_bytes()).await, - None, - true, - ), + HumanInput::Text(text) => { + (actions::insert_human_text(&cdp, &text).await, None, false) + } HumanInput::Mouse(input) => { let bytes = terminal_input::mouse_bytes(input, &mut mouse_state); ( diff --git a/codex-rs/terminal-browser/src/human_navigation.rs b/codex-rs/terminal-browser/src/human_navigation.rs new file mode 100644 index 0000000000..97f730c029 --- /dev/null +++ b/codex-rs/terminal-browser/src/human_navigation.rs @@ -0,0 +1,60 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; + +use crate::navigation; +use crate::navigation::LoadState; +use crate::navigation::NavigateRequest; +use crate::navigation::NavigationAction; +use crate::session::TerminalBrowser; +use crate::tool_dispatch::validate_browser_url; + +/// A navigation action initiated from the Codex-owned browser chrome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HumanNavigationAction { + Goto(String), + Back, + Forward, + Reload, +} + +impl TerminalBrowser { + /// Navigates the live page without yielding human control back to the model. + #[expect( + clippy::await_holding_invalid_type, + reason = "the operation and session guards serialize browser chrome actions with lifecycle changes" + )] + pub async fn navigate_for_human(&self, action: HumanNavigationAction) -> Result<()> { + anyhow::ensure!( + self.is_human_control_active(), + "human control is not active" + ); + let _operation = self + .inner + .operation + .try_lock() + .map_err(|_| anyhow!("browser_busy: another terminal browser action is running"))?; + self.flush_human_handle_invalidation().await; + + let (action, url) = match action { + HumanNavigationAction::Goto(raw_url) => ( + NavigationAction::Goto, + Some(validate_browser_url(&raw_url)?.into()), + ), + HumanNavigationAction::Back => (NavigationAction::Back, None), + HumanNavigationAction::Forward => (NavigationAction::Forward, None), + HumanNavigationAction::Reload => (NavigationAction::Reload, None), + }; + let request = NavigateRequest { + action, + url, + wait_until: LoadState::Load, + timeout_ms: None, + }; + let mut session = self.inner.session.lock().await; + let session = session.as_mut().context("terminal browser is not open")?; + session.handles.clear(); + navigation::navigate_request(&session.cdp, &request).await?; + self.refresh_page_metadata(session).await + } +} diff --git a/codex-rs/terminal-browser/src/lib.rs b/codex-rs/terminal-browser/src/lib.rs index cbe7327327..54b18e5116 100644 --- a/codex-rs/terminal-browser/src/lib.rs +++ b/codex-rs/terminal-browser/src/lib.rs @@ -8,6 +8,7 @@ mod diagnostics; mod error; mod handles; mod human_control; +mod human_navigation; mod input; mod navigation; mod network; @@ -28,6 +29,7 @@ pub use diagnostics::BrowserDoctorReport; pub use error::BrowserToolFailure; pub use error::classify_browser_error; pub use human_control::HumanControlToken; +pub use human_navigation::HumanNavigationAction; pub use input::BrowserInputModifiers; pub use input::BrowserKeyInput; pub use input::BrowserMouseButton; diff --git a/codex-rs/terminal-browser/src/runtime.rs b/codex-rs/terminal-browser/src/runtime.rs index d09003a6c6..d06e4810d9 100644 --- a/codex-rs/terminal-browser/src/runtime.rs +++ b/codex-rs/terminal-browser/src/runtime.rs @@ -87,6 +87,7 @@ impl BrowserRuntime { ); env.insert("TERM".to_string(), "xterm-256color".to_string()); env.insert("COLORTERM".to_string(), "truecolor".to_string()); + env.insert("CARBONYL_ENV_CODEX_EMBEDDER".to_string(), "1".to_string()); if let BrowserNetworkPolicy::ManagedProxy { http_addr } = network_policy { let proxy = format!("http://{http_addr}"); env.insert("HTTP_PROXY".to_string(), proxy.clone()); diff --git a/codex-rs/terminal-browser/src/runtime_tests.rs b/codex-rs/terminal-browser/src/runtime_tests.rs index b7d9bc723f..a16ca8d532 100644 --- a/codex-rs/terminal-browser/src/runtime_tests.rs +++ b/codex-rs/terminal-browser/src/runtime_tests.rs @@ -17,4 +17,8 @@ fn runtime_replaces_the_user_home_and_working_storage() { Some(&runtime.temporary.as_path().display().to_string()) ); assert!(runtime.profile.starts_with(runtime.root.as_path())); + assert_eq!( + env.get("CARBONYL_ENV_CODEX_EMBEDDER"), + Some(&"1".to_string()) + ); } diff --git a/codex-rs/terminal-browser/src/terminal_browser_tests.rs b/codex-rs/terminal-browser/src/terminal_browser_tests.rs index 3adf770857..9bb1a2de9a 100644 --- a/codex-rs/terminal-browser/src/terminal_browser_tests.rs +++ b/codex-rs/terminal-browser/src/terminal_browser_tests.rs @@ -3,6 +3,7 @@ use pretty_assertions::assert_eq; use crate::BrowserColor; use crate::BrowserLaunchContext; use crate::BrowserNetworkPolicy; +use crate::HumanNavigationAction; use crate::TerminalBrowser; use crate::TerminalSize; use crate::actions::bounded_snapshot_json; @@ -255,6 +256,7 @@ async fn configured_real_carbonyl_opens_local_page_and_snapshots() { let address = listener.local_addr().expect("smoke server address"); let server = std::thread::spawn(move || { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 15); + let mut remaining_requests = 2; loop { match listener.accept() { Ok((mut stream, _)) => { @@ -272,7 +274,10 @@ async fn configured_real_carbonyl_opens_local_page_and_snapshots() { stream .write_all(response.as_bytes()) .expect("write smoke response"); - return; + remaining_requests -= 1; + if remaining_requests == 0 { + return; + } } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { assert!( @@ -352,6 +357,18 @@ async fn configured_real_carbonyl_opens_local_page_and_snapshots() { ); tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 20)).await; } + let token = browser + .toggle_human_control(browser.human_control_token()) + .await + .expect("begin smoke-test human control"); + browser + .navigate_for_human(HumanNavigationAction::Reload) + .await + .expect("reload from the Codex-owned browser chrome"); + browser + .end_human_control(token) + .await + .expect("end smoke-test human control"); browser.close().await; server.join().expect("smoke server thread"); } diff --git a/codex-rs/terminal-browser/src/terminal_input.rs b/codex-rs/terminal-browser/src/terminal_input.rs index b357b9b55f..7ab0cbbaf7 100644 --- a/codex-rs/terminal-browser/src/terminal_input.rs +++ b/codex-rs/terminal-browser/src/terminal_input.rs @@ -19,7 +19,7 @@ pub(crate) fn key_bytes(input: &BrowserKeyInput) -> Option> { if let Some(text) = input.text.as_deref() && !text.is_empty() { - return Some(text.as_bytes().to_vec()); + return text.is_ascii().then(|| text.as_bytes().to_vec()); } match input.key.as_str() { "Enter" => Some(vec![b'\r']), diff --git a/codex-rs/terminal-browser/src/terminal_input_tests.rs b/codex-rs/terminal-browser/src/terminal_input_tests.rs index e4915d761f..83cd8e222d 100644 --- a/codex-rs/terminal-browser/src/terminal_input_tests.rs +++ b/codex-rs/terminal-browser/src/terminal_input_tests.rs @@ -49,6 +49,18 @@ fn modified_printable_shortcuts_fall_back_to_cdp() { assert_eq!(key_bytes(&control_c), None); } +#[test] +fn unicode_text_falls_back_to_cdp() { + let unicode = BrowserKeyInput { + key: "é".to_string(), + code: "é".to_string(), + text: Some("é".to_string()), + modifiers: BrowserInputModifiers::default(), + }; + + assert_eq!(key_bytes(&unicode), None); +} + #[test] fn mouse_reports_preserve_carbonyl_coordinates_and_button_state() { let mut state = HumanTerminalMouseState::default(); diff --git a/codex-rs/terminal-browser/src/tool_dispatch.rs b/codex-rs/terminal-browser/src/tool_dispatch.rs index 2e5230c879..0753fe5774 100644 --- a/codex-rs/terminal-browser/src/tool_dispatch.rs +++ b/codex-rs/terminal-browser/src/tool_dispatch.rs @@ -278,7 +278,7 @@ impl TerminalBrowser { ))) } - async fn refresh_page_metadata(&self, session: &mut BrowserSession) -> Result<()> { + pub(crate) async fn refresh_page_metadata(&self, session: &mut BrowserSession) -> Result<()> { let metadata = actions::page_metadata(&session.cdp).await?; if let Some(url) = metadata.url.as_deref() && !is_allowed_browser_url(url) @@ -306,7 +306,7 @@ async fn blank_disallowed_page(session: &mut BrowserSession) { session.handles.clear(); } -fn validate_browser_url(raw_url: &str) -> Result { +pub(crate) fn validate_browser_url(raw_url: &str) -> Result { anyhow::ensure!( raw_url.len() <= MAX_URL_INPUT_BYTES, "URL exceeds the 4 KiB input limit" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1dce54d32c..54f881fa9f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -540,6 +540,8 @@ pub(crate) struct App { pub(crate) overlay: Option, /// Carbonyl runtime shared by the active conversation and the browser panel. pub(crate) terminal_browser: Option>, + /// Focus and editing state for the Codex-owned terminal-browser chrome. + terminal_browser_chrome: crate::terminal_browser::BrowserChromeState, /// 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. @@ -1066,6 +1068,7 @@ See the Codex keymap documentation for supported actions and examples." keymap: runtime_keymap, overlay: None, terminal_browser: None, + terminal_browser_chrome: crate::terminal_browser::BrowserChromeState::default(), terminal_browser_owner_thread_id: None, terminal_browser_generation: 0, terminal_browser_reopenable: HashMap::new(), @@ -1321,6 +1324,17 @@ See the Codex keymap documentation for supported actions and examples." self.handle_draw_pre_render(tui)?; } + if let TuiEvent::MousePrimary(mouse_event) = &event + && self + .handle_terminal_browser_chrome_mouse(*mouse_event) + .await + { + let raw_mouse_events = self.sync_terminal_browser_panel(); + tui.set_raw_mouse_events(raw_mouse_events); + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + if let TuiEvent::MousePrimary(mouse_event) = &event && let Some(click) = self.terminal_browser_control_click(*mouse_event) { @@ -1347,6 +1361,7 @@ See the Codex keymap documentation for supported actions and examples." if kind == KeyEventKind::Press && let Some(target) = self.active_terminal_browser_control_target() { + self.terminal_browser_chrome.focus_page(); self.app_event_tx .unscoped() .send(AppEvent::EndTerminalBrowserControl { target }); @@ -1354,10 +1369,18 @@ See the Codex keymap documentation for supported actions and examples." return Ok(AppRunControl::Continue); } TuiEvent::Key(key_event) => { + if self.handle_terminal_browser_chrome_key(key_event) { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } self.forward_terminal_browser_key(key_event); return Ok(AppRunControl::Continue); } TuiEvent::Paste(pasted) => { + if self.handle_terminal_browser_chrome_paste(&pasted) { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } self.forward_terminal_browser_text(&pasted.replace("\r", "\n")); return Ok(AppRunControl::Continue); } @@ -1372,11 +1395,13 @@ See the Codex keymap documentation for supported actions and examples." browser_viewport, ) && let Some(target) = self.active_terminal_browser_control_target() { + self.terminal_browser_chrome.focus_page(); self.end_terminal_browser_control(target); tui.set_raw_mouse_events(/*raw_mouse_events*/ false); self.handle_owned_screen_mouse_primary(tui, mouse_event); return Ok(AppRunControl::Continue); } + self.terminal_browser_chrome.focus_page(); self.forward_terminal_browser_mouse(mouse_event.into(), browser_viewport) .await; return Ok(AppRunControl::Continue); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0b92e0f942..4ffc53afd2 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -530,6 +530,15 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::TerminalBrowserNavigationCompleted { target, error } => { + if self.terminal_browser_control_target_is_current(target) { + if let Some(error) = error { + self.chat_widget + .add_error_message(format!("Browser navigation failed: {error}")); + } + tui.frame_requester().schedule_frame(); + } + } AppEvent::TerminalBrowserClosed | AppEvent::TerminalBrowserUpdated => { let raw_mouse_events = self.sync_terminal_browser_panel(); tui.set_raw_mouse_events(raw_mouse_events); diff --git a/codex-rs/tui/src/app/owned_screen.rs b/codex-rs/tui/src/app/owned_screen.rs index 7e691ee786..a6831ab349 100644 --- a/codex-rs/tui/src/app/owned_screen.rs +++ b/codex-rs/tui/src/app/owned_screen.rs @@ -55,6 +55,7 @@ struct RenderedOwnedScreen { struct OwnedScreenBrowser<'a> { runtime: Option<&'a Arc>, view: &'a codex_terminal_browser::BrowserView, + chrome: &'a crate::terminal_browser::BrowserChromeState, } impl OwnedScreen { @@ -705,6 +706,11 @@ impl App { } let terminal_browser = self.terminal_browser_for_current_thread(); let terminal_browser_view = terminal_browser.as_ref().map(|browser| browser.view()); + self.terminal_browser_chrome.sync_url( + terminal_browser_view + .as_ref() + .and_then(|view| view.url.as_deref()), + ); let browser_human_control = terminal_browser_view .as_ref() .is_some_and(|view| view.human_control); @@ -713,6 +719,7 @@ impl App { .map(|view| OwnedScreenBrowser { runtime: terminal_browser.as_ref(), view, + chrome: &self.terminal_browser_chrome, }); let mut rendered_area = Rect::default(); let mut selection_autoscroll_active = false; @@ -795,7 +802,8 @@ fn render_owned_screen_contents( tracing::debug!(%error, "failed to resize terminal browser panel"); } } - let browser_render = render_browser_view(browser.view, body, buffer); + let browser_render = + render_browser_view(browser.view, browser.chrome, body, buffer); debug_assert_eq!(browser_render.viewport, browser_viewport(body)); if let Some(rendered) = &mut rendered { rendered.browser_cursor = browser_render.cursor; diff --git a/codex-rs/tui/src/app/owned_screen_tests.rs b/codex-rs/tui/src/app/owned_screen_tests.rs index 54898a4e18..af660fcb56 100644 --- a/codex-rs/tui/src/app/owned_screen_tests.rs +++ b/codex-rs/tui/src/app/owned_screen_tests.rs @@ -177,12 +177,15 @@ fn render_frame_app_with_browser( browser_view: Option<&BrowserView>, ) -> Terminal { let frame_overlays_enabled = app.chat_widget.no_modal_or_popup_active(); + let mut browser_chrome = crate::terminal_browser::BrowserChromeState::default(); + browser_chrome.sync_url(browser_view.and_then(|view| view.url.as_deref())); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); terminal .draw(|frame| { let browser = browser_view.map(|view| OwnedScreenBrowser { runtime: None, view, + chrome: &browser_chrome, }); let rendered = render_owned_screen_contents( &mut app.chat_widget, diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_preserves_narrow_overlay_chrome.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_preserves_narrow_overlay_chrome.snap index aa57fa3511..426cb52d27 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_preserves_narrow_overlay_chrome.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_preserves_narrow_overlay_chrome.snap @@ -3,8 +3,8 @@ source: tui/src/app/owned_screen_tests.rs expression: terminal.backend() --- "committed response ┌ Summary | Browser ─────────────┐ " -" │ running Example │ " -" │ https://example.com │ " +" │ [<] [>] [R] Example running │ " +" │ URL https://example.com │ " " │Codex browser panel │ " " │interactive page │ " " │ready │ " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_wide_right_rail.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_wide_right_rail.snap index 1b8ed5da1a..7b92143619 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_wide_right_rail.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_screen__tests__owned_screen_browser_renders_in_wide_right_rail.snap @@ -3,8 +3,8 @@ source: tui/src/app/owned_screen_tests.rs expression: terminal.backend() --- " Tasks │committed response ┃ Summary | Browser " -" New task /new │ ┃ running Example " -" Search /resume │ ┃ https://example.com " +" New task /new │ ┃ [<] [>] [R] Example running " +" Search /resume │ ┃ URL https://example.com " " │ ┃Codex browser panel " " Current │ ┃interactive page " " • Parent │ ┃ready " diff --git a/codex-rs/tui/src/app/terminal_browser.rs b/codex-rs/tui/src/app/terminal_browser.rs index 78e70cf7e9..97b5889452 100644 --- a/codex-rs/tui/src/app/terminal_browser.rs +++ b/codex-rs/tui/src/app/terminal_browser.rs @@ -14,6 +14,7 @@ use codex_terminal_browser::BrowserNetworkPolicy; use codex_terminal_browser::BrowserStatus; use codex_terminal_browser::BrowserToolOutput; use codex_terminal_browser::BrowserView; +use codex_terminal_browser::HumanNavigationAction; use codex_terminal_browser::TerminalBrowser; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyEvent; @@ -28,11 +29,14 @@ use crate::app_event::OwnedScreenPanel; use crate::app_event::TerminalBrowserControlTarget; use crate::app_event::TerminalBrowserProfileApproval; use crate::app_event::TerminalBrowserProfileCommand; +use crate::terminal_browser::BrowserChromeKeyResult; +use crate::terminal_browser::BrowserChromeMouseResult; use crate::terminal_browser::BrowserMouseRoute; use crate::terminal_browser::TerminalBrowserNetworkAvailability; use crate::terminal_browser::browser_key_input; use crate::terminal_browser::browser_mouse_input; use crate::terminal_browser::browser_mouse_route; +use crate::terminal_browser::browser_panel_areas; use crate::terminal_browser::dynamic_tool_response; use crate::terminal_browser::profile_approval_view_params; use crate::tui::MousePrimaryEvent; @@ -363,6 +367,7 @@ impl App { } pub(super) fn hide_terminal_browser_panel(&mut self) { + self.terminal_browser_chrome.focus_page(); if let Some(browser) = self.terminal_browser_for_current_thread() { let invalidate_human_handles = browser.is_human_control_active(); browser.set_visibility(/*visible*/ false); @@ -401,6 +406,100 @@ impl App { } } + pub(super) fn handle_terminal_browser_chrome_key(&mut self, event: KeyEvent) -> bool { + match self.terminal_browser_chrome.handle_key(event) { + BrowserChromeKeyResult::Ignored => false, + BrowserChromeKeyResult::Consumed => true, + BrowserChromeKeyResult::Navigate(action) => { + self.navigate_terminal_browser_from_chrome(action); + true + } + } + } + + pub(super) fn handle_terminal_browser_chrome_paste(&mut self, pasted: &str) -> bool { + self.terminal_browser_chrome.handle_paste(pasted) + } + + pub(super) async fn handle_terminal_browser_chrome_mouse( + &mut self, + event: MousePrimaryEvent, + ) -> bool { + if self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + || self.owned_screen_frame.right_rail_content() != OwnedScreenRightRailContent::Browser + || !self + .terminal_browser_for_current_thread() + .is_some_and(|browser| browser.view().visible) + { + return false; + } + let Some(header) = self + .owned_screen_frame + .panel_body(OwnedScreenPanel::Summary) + .map(browser_panel_areas) + .map(|areas| areas.header) + else { + return false; + }; + let BrowserChromeMouseResult::Consumed(navigation) = self + .terminal_browser_chrome + .handle_mouse_primary(event, header) + else { + return false; + }; + let Some(browser) = self.terminal_browser_for_current_thread() else { + return true; + }; + if !browser.is_human_control_active() { + let Some(target) = self.terminal_browser_control_target() else { + return true; + }; + let outcome = browser.toggle_human_control(target.token).await; + let mut completion_target = target; + let error = match outcome { + Ok(token) => { + completion_target.token = token; + None + } + Err(error) => Some(error.to_string()), + }; + let control_failed = error.is_some(); + self.app_event_tx + .unscoped() + .send(AppEvent::TerminalBrowserControlCompleted { + target: completion_target, + error, + }); + if control_failed { + self.terminal_browser_chrome.focus_page(); + return true; + } + } + if let Some(navigation) = navigation { + self.navigate_terminal_browser_from_chrome(navigation); + } + true + } + + fn navigate_terminal_browser_from_chrome(&mut self, action: HumanNavigationAction) { + let Some(browser) = self.terminal_browser_for_current_thread() else { + return; + }; + let Some(target) = self.active_terminal_browser_control_target() else { + return; + }; + let app_event_tx = self.app_event_tx.unscoped(); + tokio::spawn(async move { + let error = browser + .navigate_for_human(action) + .await + .err() + .map(|error| error.to_string()); + app_event_tx.send(AppEvent::TerminalBrowserNavigationCompleted { target, error }); + }); + } + pub(super) async fn forward_terminal_browser_mouse( &mut self, mouse_event: MouseEvent, @@ -607,6 +706,7 @@ impl App { } fn take_terminal_browser_for_thread_change(&mut self) -> Option> { + self.terminal_browser_chrome.focus_page(); self.terminal_browser_owner_thread_id = None; self.terminal_browser_generation = self.terminal_browser_generation.wrapping_add(/*rhs*/ 1); @@ -818,6 +918,7 @@ impl App { if !self.terminal_browser_control_target_is_current(target) { return; } + self.terminal_browser_chrome.focus_page(); let Some(browser) = self.terminal_browser_for_current_thread() else { return; }; diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 5c6441eed7..a8d254dbba 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -46,6 +46,7 @@ pub(super) async fn make_test_app_with_channels() -> ( runtime_permission_profile_override: None, overlay: None, terminal_browser: None, + terminal_browser_chrome: crate::terminal_browser::BrowserChromeState::default(), terminal_browser_owner_thread_id: None, terminal_browser_generation: 0, terminal_browser_reopenable: HashMap::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index cc98c922f2..cbf159001b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4612,6 +4612,7 @@ async fn make_test_app() -> App { runtime_permission_profile_override: None, overlay: None, terminal_browser: None, + terminal_browser_chrome: crate::terminal_browser::BrowserChromeState::default(), terminal_browser_owner_thread_id: None, terminal_browser_generation: 0, terminal_browser_reopenable: HashMap::new(), @@ -4684,6 +4685,7 @@ async fn make_test_app_with_channels() -> ( runtime_permission_profile_override: None, overlay: None, terminal_browser: None, + terminal_browser_chrome: crate::terminal_browser::BrowserChromeState::default(), terminal_browser_owner_thread_id: None, terminal_browser_generation: 0, terminal_browser_reopenable: HashMap::new(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5fb6fa0dd7..40659c4bc1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -403,6 +403,12 @@ pub(crate) enum AppEvent { error: Option, }, + /// A navigation action from the Codex-owned browser chrome completed. + TerminalBrowserNavigationCompleted { + target: TerminalBrowserControlTarget, + error: Option, + }, + /// The terminal-browser process finished closing. TerminalBrowserClosed, diff --git a/codex-rs/tui/src/terminal_browser/chrome.rs b/codex-rs/tui/src/terminal_browser/chrome.rs new file mode 100644 index 0000000000..f21443253c --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/chrome.rs @@ -0,0 +1,352 @@ +use codex_terminal_browser::BrowserStatus; +use codex_terminal_browser::BrowserView; +use codex_terminal_browser::HumanNavigationAction; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthChar; + +use crate::tui::MousePrimaryEvent; +use crate::tui::MousePrimaryEventKind; + +const MAX_URL_BYTES: usize = 4 * 1024; +const NAVIGATION_CONTROLS: &str = " [<] [>] [R] "; +const LOCATION_PREFIX: &str = " URL "; +const LOCATION_PREFIX_WIDTH: u16 = 5; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum BrowserChromeFocus { + #[default] + Page, + Location, + LocationSelected, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct BrowserChromeState { + focus: BrowserChromeFocus, + draft: String, + cursor: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum BrowserChromeKeyResult { + Ignored, + Consumed, + Navigate(HumanNavigationAction), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum BrowserChromeMouseResult { + Ignored, + Consumed(Option), +} + +impl BrowserChromeState { + pub(crate) fn sync_url(&mut self, url: Option<&str>) { + if self.is_location_focused() { + return; + } + let url = url.unwrap_or("about:blank"); + if self.draft != url { + self.draft = url.to_string(); + self.cursor = self.draft.len(); + } + } + + pub(crate) fn is_location_focused(&self) -> bool { + matches!( + self.focus, + BrowserChromeFocus::Location | BrowserChromeFocus::LocationSelected + ) + } + + pub(crate) fn focus_page(&mut self) { + self.focus = BrowserChromeFocus::Page; + } + + pub(crate) fn focus_location(&mut self) { + self.focus = BrowserChromeFocus::LocationSelected; + self.cursor = self.draft.len(); + } + + pub(crate) fn handle_key(&mut self, event: KeyEvent) -> BrowserChromeKeyResult { + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return BrowserChromeKeyResult::Ignored; + } + if self.focus == BrowserChromeFocus::Page { + if event.code == KeyCode::Char('l') + && event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) + { + self.focus_location(); + return BrowserChromeKeyResult::Consumed; + } + return BrowserChromeKeyResult::Ignored; + } + + match event.code { + KeyCode::Esc => { + self.focus_page(); + BrowserChromeKeyResult::Consumed + } + KeyCode::Enter => { + let raw_url = self.draft.trim(); + if raw_url.is_empty() { + return BrowserChromeKeyResult::Consumed; + } + let url = if raw_url.contains("://") { + raw_url.to_string() + } else { + format!("https://{raw_url}") + }; + self.focus_page(); + BrowserChromeKeyResult::Navigate(HumanNavigationAction::Goto(url)) + } + KeyCode::Char('a') + if event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) => + { + self.cursor = self.draft.len(); + self.focus = BrowserChromeFocus::LocationSelected; + BrowserChromeKeyResult::Consumed + } + KeyCode::Char(character) + if !event.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + let mut encoded = [0; 4]; + let character = character.encode_utf8(&mut encoded); + self.clear_selection(); + if self.draft.len().saturating_add(character.len()) <= MAX_URL_BYTES { + self.draft.insert_str(self.cursor, character); + self.cursor += character.len(); + } + BrowserChromeKeyResult::Consumed + } + KeyCode::Backspace => { + if !self.clear_selection() && self.cursor > 0 { + let previous = previous_char_boundary(&self.draft, self.cursor); + self.draft.drain(previous..self.cursor); + self.cursor = previous; + } + BrowserChromeKeyResult::Consumed + } + KeyCode::Delete => { + if !self.clear_selection() && self.cursor < self.draft.len() { + let next = next_char_boundary(&self.draft, self.cursor); + self.draft.drain(self.cursor..next); + } + BrowserChromeKeyResult::Consumed + } + KeyCode::Left => { + self.cursor = if self.focus == BrowserChromeFocus::LocationSelected { + 0 + } else { + previous_char_boundary(&self.draft, self.cursor) + }; + self.focus = BrowserChromeFocus::Location; + BrowserChromeKeyResult::Consumed + } + KeyCode::Right => { + self.cursor = if self.focus == BrowserChromeFocus::LocationSelected { + self.draft.len() + } else { + next_char_boundary(&self.draft, self.cursor) + }; + self.focus = BrowserChromeFocus::Location; + BrowserChromeKeyResult::Consumed + } + KeyCode::Home => { + self.cursor = 0; + self.focus = BrowserChromeFocus::Location; + BrowserChromeKeyResult::Consumed + } + KeyCode::End => { + self.cursor = self.draft.len(); + self.focus = BrowserChromeFocus::Location; + BrowserChromeKeyResult::Consumed + } + _ => BrowserChromeKeyResult::Consumed, + } + } + + pub(crate) fn handle_paste(&mut self, pasted: &str) -> bool { + if !self.is_location_focused() { + return false; + } + self.clear_selection(); + let remaining = MAX_URL_BYTES.saturating_sub(self.draft.len()); + let end = pasted.floor_char_boundary(remaining.min(pasted.len())); + self.draft.insert_str(self.cursor, &pasted[..end]); + self.cursor += end; + true + } + + fn clear_selection(&mut self) -> bool { + if self.focus != BrowserChromeFocus::LocationSelected { + return false; + } + self.draft.clear(); + self.cursor = 0; + self.focus = BrowserChromeFocus::Location; + true + } + + pub(crate) fn handle_mouse_primary( + &mut self, + event: MousePrimaryEvent, + header: Rect, + ) -> BrowserChromeMouseResult { + if header.is_empty() || !header.contains((event.column, event.row).into()) { + return BrowserChromeMouseResult::Ignored; + } + if event.kind != MousePrimaryEventKind::Press { + return BrowserChromeMouseResult::Consumed(None); + } + let column = event.column.saturating_sub(header.x); + let row = event.row.saturating_sub(header.y); + let navigation = if row == 0 { + match column { + 1..=3 => Some(HumanNavigationAction::Back), + 5..=7 => Some(HumanNavigationAction::Forward), + 9..=11 => Some(HumanNavigationAction::Reload), + _ => None, + } + } else if row == 1 { + self.focus_location(); + None + } else { + None + }; + BrowserChromeMouseResult::Consumed(navigation) + } + + fn location_view(&self, width: u16) -> LocationSlice<'_> { + if width == 0 { + return LocationSlice { + text: "", + cursor_col: 0, + }; + } + let (start, before_width) = if self.focus == BrowserChromeFocus::Page { + (0, 0) + } else { + let cursor_budget = usize::from(width.saturating_sub(/*rhs*/ 1)); + let mut start = self.cursor; + let mut before_width = 0; + for (index, character) in self.draft[..self.cursor].char_indices().rev() { + let character_width = character.width().unwrap_or(/*default*/ 0); + if before_width + character_width > cursor_budget { + break; + } + before_width += character_width; + start = index; + } + (start, before_width) + }; + let mut end = start; + let mut visible_width = 0; + for (offset, character) in self.draft[start..].char_indices() { + let character_width = character.width().unwrap_or(/*default*/ 0); + if visible_width + character_width > usize::from(width) { + break; + } + visible_width += character_width; + end = start + offset + character.len_utf8(); + } + LocationSlice { + text: &self.draft[start..end], + cursor_col: u16::try_from(before_width).unwrap_or(/*default*/ u16::MAX), + } + } +} + +struct LocationSlice<'a> { + text: &'a str, + cursor_col: u16, +} + +pub(crate) fn render_browser_chrome( + view: &BrowserView, + state: &BrowserChromeState, + area: Rect, + buf: &mut Buffer, +) -> Option<(u16, u16)> { + if area.height == 0 { + return None; + } + let title = view.title.as_deref().unwrap_or("Carbonyl"); + let status = status_label(&view.status); + Line::from(vec![ + NAVIGATION_CONTROLS.cyan(), + format!("{title} ").into(), + status.dim(), + ]) + .render_ref(area, buf); + if area.height == 1 { + return None; + } + let location_width = area.width.saturating_sub(LOCATION_PREFIX_WIDTH); + let location = state.location_view(location_width); + let location_style = if state.is_location_focused() { + location.text.reversed() + } else { + location.text.dim() + }; + Line::from(vec![LOCATION_PREFIX.cyan(), location_style]).render_ref( + Rect::new( + area.x, + area.y.saturating_add(/*rhs*/ 1), + area.width, + /*height*/ 1, + ), + buf, + ); + state.is_location_focused().then(|| { + ( + area.x + .saturating_add(LOCATION_PREFIX_WIDTH) + .saturating_add(location.cursor_col) + .min(area.right().saturating_sub(/*rhs*/ 1)), + area.y.saturating_add(/*rhs*/ 1), + ) + }) +} + +fn status_label(status: &BrowserStatus) -> &'static str { + match status { + BrowserStatus::Unavailable { .. } => "unavailable", + BrowserStatus::Idle => "idle", + BrowserStatus::Starting => "starting", + BrowserStatus::Running => "running", + BrowserStatus::Crashed { .. } => "crashed", + } +} + +fn previous_char_boundary(text: &str, cursor: usize) -> usize { + text[..cursor] + .char_indices() + .next_back() + .map_or(/*default*/ 0, |(index, _)| index) +} + +fn next_char_boundary(text: &str, cursor: usize) -> usize { + text[cursor..] + .char_indices() + .nth(/*n*/ 1) + .map_or(text.len(), |(offset, _)| cursor + offset) +} + +#[cfg(test)] +#[path = "chrome_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_browser/chrome_tests.rs b/codex-rs/tui/src/terminal_browser/chrome_tests.rs new file mode 100644 index 0000000000..27ee01b3f3 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/chrome_tests.rs @@ -0,0 +1,73 @@ +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; + +use super::*; + +#[test] +fn long_location_keeps_the_cursor_visible_at_both_ends() { + let mut state = BrowserChromeState::default(); + state.sync_url(Some("https://example.com/a/very/long/location")); + assert_eq!(state.location_view(/*width*/ 12).text, "https://exam"); + state.focus = BrowserChromeFocus::Location; + + let end = state.location_view(/*width*/ 12); + assert_eq!(end.text, "ng/location"); + assert_eq!(end.cursor_col, 11); + + state.cursor = 0; + let start = state.location_view(/*width*/ 12); + assert_eq!(start.text, "https://exam"); + assert_eq!(start.cursor_col, 0); +} + +#[test] +fn location_editor_replaces_selected_url_and_normalizes_hostnames() { + let mut state = BrowserChromeState::default(); + state.sync_url(Some("https://old.example")); + + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + BrowserChromeKeyResult::Consumed + ); + assert!(state.handle_paste("openai.com")); + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + BrowserChromeKeyResult::Navigate(HumanNavigationAction::Goto( + "https://openai.com".to_string() + )) + ); + assert!(!state.is_location_focused()); + let mut state = BrowserChromeState::default(); + state.sync_url(Some("https://example.com/café")); + state.focus = BrowserChromeFocus::Location; + assert_eq!( + state.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), + BrowserChromeKeyResult::Consumed + ); + assert_eq!(state.draft, "https://example.com/caf"); +} + +#[test] +fn navigation_controls_have_distinct_click_targets() { + let mut state = BrowserChromeState::default(); + let header = Rect::new( + /*x*/ 20, /*y*/ 10, /*width*/ 40, /*height*/ 2, + ); + let click = |column| MousePrimaryEvent { + kind: MousePrimaryEventKind::Press, + column, + row: 10, + modifiers: KeyModifiers::NONE, + }; + + for (column, action) in [ + (22, HumanNavigationAction::Back), + (26, HumanNavigationAction::Forward), + (30, HumanNavigationAction::Reload), + ] { + assert_eq!( + state.handle_mouse_primary(click(column), header), + BrowserChromeMouseResult::Consumed(Some(action)) + ); + } +} diff --git a/codex-rs/tui/src/terminal_browser/mod.rs b/codex-rs/tui/src/terminal_browser/mod.rs index 7ea80335ee..c240ecab27 100644 --- a/codex-rs/tui/src/terminal_browser/mod.rs +++ b/codex-rs/tui/src/terminal_browser/mod.rs @@ -4,12 +4,17 @@ //! shared browser runtime to a frame-assigned panel rectangle, crossterm input, profile approval //! UI, and app-server dynamic tools. +mod chrome; mod input; mod network; mod panel; mod profile_approval; mod tools; +pub(crate) use chrome::BrowserChromeKeyResult; +pub(crate) use chrome::BrowserChromeMouseResult; +pub(crate) use chrome::BrowserChromeState; +pub(crate) use chrome::render_browser_chrome; pub(crate) use input::BrowserMouseRoute; pub(crate) use input::browser_key_input; pub(crate) use input::browser_mouse_input; @@ -18,7 +23,6 @@ pub(crate) use network::TerminalBrowserNetworkAvailability; #[cfg(test)] pub(crate) use panel::BrowserPanelAreas; pub(crate) use panel::TerminalBrowserPanel; -#[cfg(test)] pub(crate) use panel::browser_panel_areas; pub(crate) use panel::browser_viewport; pub(crate) use panel::render_browser_view; diff --git a/codex-rs/tui/src/terminal_browser/panel.rs b/codex-rs/tui/src/terminal_browser/panel.rs index 28cf2f3fe4..b82878dc0e 100644 --- a/codex-rs/tui/src/terminal_browser/panel.rs +++ b/codex-rs/tui/src/terminal_browser/panel.rs @@ -17,6 +17,9 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use super::BrowserChromeState; +use super::render_browser_chrome; + const HEADER_HEIGHT: u16 = 2; const FOOTER_HEIGHT: u16 = 1; @@ -89,17 +92,18 @@ impl TerminalBrowserPanel { pub(crate) fn render_browser_view( view: &BrowserView, + chrome: &BrowserChromeState, area: Rect, buf: &mut Buffer, ) -> BrowserPanelRender { Clear.render(area, buf); let areas = browser_panel_areas(area); - render_header(view, areas.header, buf); + let chrome_cursor = render_browser_chrome(view, chrome, areas.header, buf); render_screen_or_status(view, areas.viewport, buf); render_footer(view, areas.footer, buf); BrowserPanelRender { viewport: areas.viewport, - cursor: browser_cursor(view, areas.viewport), + cursor: chrome_cursor.or_else(|| browser_cursor(view, areas.viewport)), } } @@ -120,27 +124,6 @@ fn browser_cursor(view: &BrowserView, viewport: Rect) -> Option<(u16, u16)> { }) } -fn render_header(view: &BrowserView, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let title = view.title.as_deref().unwrap_or("Carbonyl"); - let status = status_label(&view.status); - Line::from(vec![format!(" {status} ").cyan().bold(), title.into()]).render_ref(area, buf); - if area.height > 1 { - let url = view.url.as_deref().unwrap_or("about:blank"); - Line::from(vec![" ".into(), url.dim()]).render_ref( - Rect::new( - area.x, - area.y.saturating_add(/*rhs*/ 1), - area.width, - /*height*/ 1, - ), - buf, - ); - } -} - fn render_footer(view: &BrowserView, area: Rect, buf: &mut Buffer) { if area.height == 0 { return; @@ -276,16 +259,6 @@ fn color(color: BrowserColor) -> Option { } } -fn status_label(status: &BrowserStatus) -> &'static str { - match status { - BrowserStatus::Unavailable { .. } => "unavailable", - BrowserStatus::Idle => "idle", - BrowserStatus::Starting => "starting", - BrowserStatus::Running => "running", - BrowserStatus::Crashed { .. } => "crashed", - } -} - fn status_message(status: &BrowserStatus) -> String { match status { BrowserStatus::Unavailable { reason } => format!("Browser unavailable: {reason}"), @@ -316,7 +289,9 @@ pub(super) fn render_view_for_test( area: Rect, buf: &mut Buffer, ) -> BrowserPanelRender { - render_browser_view(view, area, buf) + let mut chrome = BrowserChromeState::default(); + chrome.sync_url(view.url.as_deref()); + render_browser_view(view, &chrome, area, buf) } #[cfg(test)] diff --git a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap index cd5cc43e33..90e45d2342 100644 --- a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap @@ -2,8 +2,8 @@ source: tui/src/terminal_browser/tests.rs expression: "buffer_text(&buffer, area)" --- - running Example - https://example.com + [<] [>] [R] Example running + URL https://example.com User diff --git a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap index d2e9f81584..88279fb496 100644 --- a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap @@ -2,8 +2,8 @@ source: tui/src/terminal_browser/tests.rs expression: "buffer_text(&buffer, area)" --- - running Example - https://example.com + [<] [>] [R] Example running + URL https://example.com Code x UI diff --git a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap index fc16e65651..3d7fee7e23 100644 --- a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap @@ -2,8 +2,8 @@ source: tui/src/terminal_browser/tests.rs expression: "buffer_text(&buffer, area)" --- - unavailable Carbonyl - about:blank + [<] [>] [R] Carbonyl unavailable + URL about:blank Browser unavailable: Carbonyl was not found on PATH; install it or set CODEX_CARBONYL_BINARY