From a80dffef64dde4c63f3280cd74e035223dc8f88e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 5 Jul 2026 20:50:52 -0300 Subject: [PATCH] feat(tui): add carbonyl browser panel adapter --- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/terminal_browser/input.rs | 141 ++++++ codex-rs/tui/src/terminal_browser/mod.rs | 26 ++ codex-rs/tui/src/terminal_browser/panel.rs | 285 +++++++++++ .../src/terminal_browser/profile_approval.rs | 97 ++++ ..._browser_human_control_panel_snapshot.snap | 14 + ...ests__terminal_browser_panel_snapshot.snap | 14 + ...wser_profile_forget_approval_snapshot.snap | 16 + ...available_panel_wraps_reason_snapshot.snap | 14 + codex-rs/tui/src/terminal_browser/tests.rs | 441 ++++++++++++++++++ codex-rs/tui/src/terminal_browser/tools.rs | 259 ++++++++++ 11 files changed, 1308 insertions(+) create mode 100644 codex-rs/tui/src/terminal_browser/input.rs create mode 100644 codex-rs/tui/src/terminal_browser/mod.rs create mode 100644 codex-rs/tui/src/terminal_browser/panel.rs create mode 100644 codex-rs/tui/src/terminal_browser/profile_approval.rs create mode 100644 codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap create mode 100644 codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap create mode 100644 codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_profile_forget_approval_snapshot.snap create mode 100644 codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap create mode 100644 codex-rs/tui/src/terminal_browser/tests.rs create mode 100644 codex-rs/tui/src/terminal_browser/tools.rs diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6c0b83a72e..bc873cf9dd 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -53,6 +53,7 @@ codex-rollout = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } +codex-terminal-browser = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/tui/src/terminal_browser/input.rs b/codex-rs/tui/src/terminal_browser/input.rs new file mode 100644 index 0000000000..1c94b7107c --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/input.rs @@ -0,0 +1,141 @@ +use codex_terminal_browser::BrowserInputModifiers; +use codex_terminal_browser::BrowserKeyInput; +use codex_terminal_browser::BrowserMouseButton; +use codex_terminal_browser::BrowserMouseInput; +use codex_terminal_browser::BrowserMouseKind; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Rect; + +/// Converts a crossterm key event into Carbonyl input. +/// +/// Release events and keys without a Chromium keyboard equivalent are ignored. +pub(crate) fn browser_key_input(event: KeyEvent) -> Option { + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return None; + } + let modifiers = browser_modifiers(event.modifiers); + let (key, code, text) = match event.code { + KeyCode::Char(character) => { + let code = if character.is_ascii_alphabetic() { + format!("Key{}", character.to_ascii_uppercase()) + } else if character.is_ascii_digit() { + format!("Digit{character}") + } else if character == ' ' { + "Space".to_string() + } else { + character.to_string() + }; + let text = (!modifiers.control && !modifiers.alt && !modifiers.meta) + .then(|| character.to_string()); + (character.to_string(), code, text) + } + KeyCode::Enter => ("Enter".to_string(), "Enter".to_string(), None), + KeyCode::Tab | KeyCode::BackTab => ("Tab".to_string(), "Tab".to_string(), None), + KeyCode::Backspace => ("Backspace".to_string(), "Backspace".to_string(), None), + KeyCode::Delete => ("Delete".to_string(), "Delete".to_string(), None), + KeyCode::Esc => ("Escape".to_string(), "Escape".to_string(), None), + KeyCode::Left => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), None), + KeyCode::Right => ("ArrowRight".to_string(), "ArrowRight".to_string(), None), + KeyCode::Up => ("ArrowUp".to_string(), "ArrowUp".to_string(), None), + KeyCode::Down => ("ArrowDown".to_string(), "ArrowDown".to_string(), None), + KeyCode::Home => ("Home".to_string(), "Home".to_string(), None), + KeyCode::End => ("End".to_string(), "End".to_string(), None), + KeyCode::PageUp => ("PageUp".to_string(), "PageUp".to_string(), None), + KeyCode::PageDown => ("PageDown".to_string(), "PageDown".to_string(), None), + KeyCode::F(number) => { + let key = format!("F{number}"); + (key.clone(), key, None) + } + _ => return None, + }; + let modifiers = if matches!(event.code, KeyCode::BackTab) { + BrowserInputModifiers { + shift: true, + ..modifiers + } + } else { + modifiers + }; + Some(BrowserKeyInput { + key, + code, + text, + modifiers, + }) +} + +/// Converts a crossterm mouse event into coordinates relative to `viewport`. +/// +/// Events outside the browser viewport are ignored so surrounding panel chrome retains ownership +/// of its own interactions. +pub(crate) fn browser_mouse_input(event: MouseEvent, viewport: Rect) -> Option { + if viewport.is_empty() || !viewport.contains((event.column, event.row).into()) { + return None; + } + let (kind, button) = match event.kind { + MouseEventKind::Moved => (BrowserMouseKind::Move, BrowserMouseButton::None), + MouseEventKind::Down(button) => (BrowserMouseKind::Down, browser_mouse_button(button)), + MouseEventKind::Up(button) => (BrowserMouseKind::Up, browser_mouse_button(button)), + MouseEventKind::Drag(button) => (BrowserMouseKind::Move, browser_mouse_button(button)), + MouseEventKind::ScrollUp => ( + BrowserMouseKind::Wheel { + delta_x: 0.0, + delta_y: -100.0, + }, + BrowserMouseButton::None, + ), + MouseEventKind::ScrollDown => ( + BrowserMouseKind::Wheel { + delta_x: 0.0, + delta_y: 100.0, + }, + BrowserMouseButton::None, + ), + MouseEventKind::ScrollLeft => ( + BrowserMouseKind::Wheel { + delta_x: -100.0, + delta_y: 0.0, + }, + BrowserMouseButton::None, + ), + MouseEventKind::ScrollRight => ( + BrowserMouseKind::Wheel { + delta_x: 100.0, + delta_y: 0.0, + }, + BrowserMouseButton::None, + ), + }; + Some(BrowserMouseInput { + kind, + button, + column: event.column.saturating_sub(viewport.x), + row: event.row.saturating_sub(viewport.y), + viewport_cols: viewport.width, + viewport_rows: viewport.height, + modifiers: browser_modifiers(event.modifiers), + }) +} + +fn browser_mouse_button(button: MouseButton) -> BrowserMouseButton { + match button { + MouseButton::Left => BrowserMouseButton::Left, + MouseButton::Middle => BrowserMouseButton::Middle, + MouseButton::Right => BrowserMouseButton::Right, + } +} + +fn browser_modifiers(modifiers: KeyModifiers) -> BrowserInputModifiers { + BrowserInputModifiers { + alt: modifiers.contains(KeyModifiers::ALT), + control: modifiers.contains(KeyModifiers::CONTROL), + meta: modifiers.contains(KeyModifiers::SUPER), + shift: modifiers.contains(KeyModifiers::SHIFT), + } +} diff --git a/codex-rs/tui/src/terminal_browser/mod.rs b/codex-rs/tui/src/terminal_browser/mod.rs new file mode 100644 index 0000000000..b3aed78cda --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/mod.rs @@ -0,0 +1,26 @@ +//! TUI adapters for the Carbonyl-backed terminal browser. +//! +//! Application code owns browser lifecycle, visibility, and focus. This module only adapts the +//! shared browser runtime to a frame-assigned panel rectangle, crossterm input, profile approval +//! UI, and app-server dynamic tools. + +mod input; +mod panel; +mod profile_approval; +mod tools; + +pub(crate) use input::browser_key_input; +pub(crate) use input::browser_mouse_input; +pub(crate) use panel::BrowserPanelAreas; +pub(crate) use panel::TerminalBrowserPanel; +pub(crate) use panel::browser_panel_areas; +pub(crate) use panel::browser_viewport; +pub(crate) use profile_approval::profile_approval_view_params; +pub(crate) use profile_approval::requested_profile_command; +pub(crate) use tools::TERMINAL_BROWSER_NAMESPACE; +pub(crate) use tools::dynamic_tool_response; +pub(crate) use tools::dynamic_tool_specs; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_browser/panel.rs b/codex-rs/tui/src/terminal_browser/panel.rs new file mode 100644 index 0000000000..da4b94b87d --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/panel.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +use codex_terminal_browser::BrowserCell; +use codex_terminal_browser::BrowserColor; +use codex_terminal_browser::BrowserStatus; +use codex_terminal_browser::BrowserView; +use codex_terminal_browser::TerminalBrowser; +use codex_terminal_browser::TerminalSize; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +const HEADER_HEIGHT: u16 = 2; +const FOOTER_HEIGHT: u16 = 1; + +/// Exact sub-rectangles used to render a terminal-browser panel. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct BrowserPanelAreas { + pub(crate) header: Rect, + pub(crate) viewport: Rect, + pub(crate) footer: Rect, +} + +/// Splits an already-assigned panel rectangle into browser chrome and content. +/// +/// This helper never centers, shrinks, or otherwise relocates `area`; the application frame owns +/// the outer panel geometry. +pub(crate) fn browser_panel_areas(area: Rect) -> BrowserPanelAreas { + let header_height = area.height.min(HEADER_HEIGHT); + let remaining_height = area.height.saturating_sub(header_height); + let footer_height = remaining_height.min(FOOTER_HEIGHT); + let viewport_height = remaining_height.saturating_sub(footer_height); + let header = Rect::new(area.x, area.y, area.width, header_height); + let viewport = Rect::new( + area.x, + area.y.saturating_add(header_height), + area.width, + viewport_height, + ); + let footer = Rect::new(area.x, viewport.bottom(), area.width, footer_height); + BrowserPanelAreas { + header, + viewport, + footer, + } +} + +/// Returns the Carbonyl content viewport for an exact frame-assigned panel rectangle. +pub(crate) fn browser_viewport(area: Rect) -> Rect { + browser_panel_areas(area).viewport +} + +/// Renders a shared browser runtime inside a frame-owned panel. +pub(crate) struct TerminalBrowserPanel { + browser: Arc, +} + +impl TerminalBrowserPanel { + pub(crate) fn new(browser: Arc) -> Self { + Self { browser } + } + + /// Resizes Carbonyl to the content viewport derived from `area`. + pub(crate) fn resize(&self, area: Rect) -> anyhow::Result<()> { + let viewport = browser_viewport(area); + if viewport.is_empty() { + return Ok(()); + } + self.browser.resize(TerminalSize { + rows: viewport.height, + cols: viewport.width, + }) + } + + /// Renders into exactly `area` and returns the browser content viewport used for input. + pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) -> Rect { + render_view(&self.browser.view(), area, buf) + } +} + +fn render_view(view: &BrowserView, area: Rect, buf: &mut Buffer) -> Rect { + Clear.render(area, buf); + let areas = browser_panel_areas(area); + render_header(view, areas.header, buf); + render_screen_or_status(view, areas.viewport, buf); + render_footer(view, areas.footer, buf); + areas.viewport +} + +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; + } + if view.human_control { + let line = if area.width >= 31 { + Line::from(vec![" Ctrl+] ".cyan(), "return control to Codex".dim()]) + } else if area.width >= 15 { + Line::from(vec![" Ctrl+] ".cyan(), "return".dim()]) + } else { + Line::from(" Ctrl+] ".cyan()) + }; + line.render_ref(area, buf); + } else { + let line = if area.width >= 63 { + Line::from(vec![ + " Esc ".cyan(), + "hide".dim(), + " ".into(), + "/browser control".cyan(), + " take control".dim(), + " ".into(), + "/browser close".cyan(), + " stop".dim(), + ]) + } else if area.width >= 41 { + Line::from(vec![ + " Esc ".cyan(), + "hide".dim(), + " ".into(), + "/browser control".cyan(), + " take control".dim(), + ]) + } else { + Line::from(vec![" Esc ".cyan(), "hide".dim()]) + }; + line.render_ref(area, buf); + } +} + +fn render_screen_or_status(view: &BrowserView, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + if !matches!(&view.status, BrowserStatus::Running) + || view.screen.rows == 0 + || view.screen.cols == 0 + || view.screen.cells.is_empty() + { + let message = status_message(&view.status); + let lines = textwrap::wrap(&message, usize::from(area.width).max(/*other*/ 1)) + .into_iter() + .map(|line| Line::from(line.into_owned()).dim()) + .collect::>(); + Paragraph::new(lines).render(area, buf); + return; + } + + for row in 0..view.screen.rows.min(area.height) { + for col in 0..view.screen.cols.min(area.width) { + let Some(cell) = view.screen.cell(row, col) else { + continue; + }; + let clipped_wide_glyph = col.saturating_add(/*rhs*/ 1) >= area.width + && view + .screen + .cell(row, col.saturating_add(/*rhs*/ 1)) + .is_some_and(|next| next.wide_continuation); + let symbol = if clipped_wide_glyph || cell.wide_continuation || cell.text.is_empty() { + " " + } else { + cell.text.as_str() + }; + render_cell(cell, symbol, area.x + col, area.y + row, buf); + } + } + + if let Some((row, col)) = view.screen.cursor + && row < area.height + && col < area.width + { + let cell = &mut buf[(area.x + col, area.y + row)]; + let style = cell.style().reversed(); + cell.set_style(style); + } +} + +fn render_cell(cell: &BrowserCell, symbol: &str, x: u16, y: u16, buf: &mut Buffer) { + let mut style = Style::default(); + if let Some(foreground) = color(cell.foreground) { + style = style.fg(foreground); + } + if let Some(background) = color(cell.background) { + style = style.bg(background); + } + if cell.bold { + style = style.bold(); + } + if cell.dim { + style = style.dim(); + } + if cell.italic { + style = style.italic(); + } + if cell.underlined { + style = style.underlined(); + } + if cell.reversed { + style = style.reversed(); + } + buf[(x, y)].set_symbol(symbol).set_style(style); +} + +#[expect( + clippy::disallowed_methods, + reason = "Carbonyl output carries terminal-authored indexed and RGB colors that must render exactly" +)] +fn color(color: BrowserColor) -> Option { + match color { + BrowserColor::Default => None, + BrowserColor::Indexed(index) => Some(Color::Indexed(index)), + BrowserColor::Rgb(red, green, blue) => Some(Color::Rgb(red, green, blue)), + } +} + +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}"), + BrowserStatus::Idle => "Open a page with terminal_browser.open.".to_string(), + BrowserStatus::Starting => "Starting Carbonyl...".to_string(), + BrowserStatus::Running => "Waiting for Carbonyl to render the page...".to_string(), + BrowserStatus::Crashed { message } => format!("Carbonyl exited: {message}"), + } +} + +#[cfg(test)] +pub(super) fn style_for_test(cell: &BrowserCell) -> Style { + let mut buf = Buffer::empty(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1, + )); + let symbol = if cell.wide_continuation || cell.text.is_empty() { + " " + } else { + cell.text.as_str() + }; + render_cell(cell, symbol, /*x*/ 0, /*y*/ 0, &mut buf); + buf[(0, 0)].style() +} + +#[cfg(test)] +pub(super) fn render_view_for_test(view: &BrowserView, area: Rect, buf: &mut Buffer) -> Rect { + render_view(view, area, buf) +} + +#[cfg(test)] +pub(super) fn render_screen_for_test(view: &BrowserView, area: Rect, buf: &mut Buffer) { + render_screen_or_status(view, area, buf); +} diff --git a/codex-rs/tui/src/terminal_browser/profile_approval.rs b/codex-rs/tui/src/terminal_browser/profile_approval.rs new file mode 100644 index 0000000000..4ecc5b3a52 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/profile_approval.rs @@ -0,0 +1,97 @@ +use crate::app_event::TerminalBrowserProfileCommand; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +pub(crate) fn requested_profile_command( + arguments: &serde_json::Value, +) -> Option { + let action = arguments.get("action")?.as_str()?; + let name = || { + arguments + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|name| valid_profile_name(name)) + .map(str::to_string) + }; + match action { + "requestCreate" => name().map(TerminalBrowserProfileCommand::Create), + "requestSelect" => name().map(TerminalBrowserProfileCommand::Use), + "requestEphemeral" if arguments.get("name").is_none() => { + Some(TerminalBrowserProfileCommand::Ephemeral) + } + "requestForget" => name().map(TerminalBrowserProfileCommand::Forget), + _ => None, + } +} + +fn valid_profile_name(name: &str) -> bool { + let mut bytes = name.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + name.len() <= 64 + && first.is_ascii_alphanumeric() + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +pub(crate) fn profile_approval_view_params( + command: TerminalBrowserProfileCommand, +) -> SelectionViewParams { + let (subtitle, approve_name, approve_description) = match &command { + TerminalBrowserProfileCommand::Create(name) => ( + format!("The model wants to create and select browser profile `{name}`."), + "Create and select profile", + "Store browsing data for this workspace in a named profile", + ), + TerminalBrowserProfileCommand::Use(name) => ( + format!("The model wants to select browser profile `{name}`."), + "Select profile", + "Close the current browser and use the named profile", + ), + TerminalBrowserProfileCommand::Ephemeral => ( + "The model wants to return to a fresh ephemeral browser profile.".to_string(), + "Use ephemeral profile", + "Close the current browser and discard future ephemeral data on close", + ), + TerminalBrowserProfileCommand::Forget(name) => ( + format!( + "The model wants to permanently delete browser profile `{name}` and its browsing data." + ), + "Permanently delete profile", + "This cannot be undone", + ), + TerminalBrowserProfileCommand::List => ( + "The model requested a profile listing.".to_string(), + "List profiles", + "Read profile names without changing them", + ), + }; + let approve_actions: Vec = vec![Box::new(move |tx| { + tx.send(crate::app_event::AppEvent::ManageTerminalBrowserProfile( + command.clone(), + )); + })]; + SelectionViewParams { + title: Some("Approve browser profile change?".to_string()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: approve_name.to_string(), + description: Some(approve_description.to_string()), + actions: approve_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Do not change browser profiles".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } +} 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 new file mode 100644 index 0000000000..cd5cc43e33 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_human_control_panel_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/terminal_browser/tests.rs +expression: "buffer_text(&buffer, area)" +--- + running Example + https://example.com +User + + + + + + + Ctrl+] return control to Codex 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 new file mode 100644 index 0000000000..89591748b5 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_panel_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/terminal_browser/tests.rs +expression: "buffer_text(&buffer, area)" +--- + running Example + https://example.com +Code +x UI + + + + + + Esc hide /browser control take control diff --git a/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_profile_forget_approval_snapshot.snap b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_profile_forget_approval_snapshot.snap new file mode 100644 index 0000000000..b738f58e54 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_profile_forget_approval_snapshot.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/terminal_browser/tests.rs +expression: "buffer_text(&buffer, area)" +--- + + Approve browser profile change? + The model wants to permanently delete browser profile `work` + +› 1. Permanently delete profile This cannot be undone + 2. Cancel Do not change browser + profiles + + + + + Press enter to confirm or esc to go back 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 new file mode 100644 index 0000000000..b1443f893e --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/snapshots/codex_tui__terminal_browser__tests__terminal_browser_unavailable_panel_wraps_reason_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/terminal_browser/tests.rs +expression: "buffer_text(&buffer, area)" +--- + unavailable Carbonyl + about:blank +Browser unavailable: Carbonyl was not found on +PATH; install it or set CODEX_CARBONYL_BINARY + + + + + + Esc hide /browser control take control diff --git a/codex-rs/tui/src/terminal_browser/tests.rs b/codex-rs/tui/src/terminal_browser/tests.rs new file mode 100644 index 0000000000..9b863fa56b --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/tests.rs @@ -0,0 +1,441 @@ +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::DynamicToolNamespaceTool; +use codex_app_server_protocol::DynamicToolSpec; +use codex_terminal_browser::BrowserCell; +use codex_terminal_browser::BrowserColor; +use codex_terminal_browser::BrowserInputModifiers; +use codex_terminal_browser::BrowserKeyInput; +use codex_terminal_browser::BrowserMouseButton; +use codex_terminal_browser::BrowserMouseInput; +use codex_terminal_browser::BrowserMouseKind; +use codex_terminal_browser::BrowserScreen; +use codex_terminal_browser::BrowserStatus; +use codex_terminal_browser::BrowserToolOutput; +use codex_terminal_browser::BrowserView; +use codex_terminal_browser::TerminalSize; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use tokio::sync::mpsc::unbounded_channel; + +use crate::app_event::AppEvent; +use crate::app_event::TerminalBrowserProfileCommand; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ListSelectionView; +use crate::render::renderable::Renderable; + +use super::BrowserPanelAreas; +use super::browser_key_input; +use super::browser_mouse_input; +use super::browser_panel_areas; +use super::panel::render_screen_for_test; +use super::panel::render_view_for_test; +use super::panel::style_for_test; +use super::profile_approval_view_params; +use super::requested_profile_command; +use super::tools::TERMINAL_BROWSER_NAMESPACE; +use super::tools::dynamic_tool_response; +use super::tools::dynamic_tool_specs; + +#[test] +fn panel_areas_partition_the_exact_assigned_rectangle() { + let area = Rect::new( + /*x*/ 7, /*y*/ 3, /*width*/ 48, /*height*/ 10, + ); + + assert_eq!( + browser_panel_areas(area), + BrowserPanelAreas { + header: Rect::new( + /*x*/ 7, /*y*/ 3, /*width*/ 48, /*height*/ 2, + ), + viewport: Rect::new( + /*x*/ 7, /*y*/ 5, /*width*/ 48, /*height*/ 7, + ), + footer: Rect::new( + /*x*/ 7, /*y*/ 12, /*width*/ 48, /*height*/ 1, + ), + } + ); +} + +#[test] +fn tiny_panel_reserves_no_overlapping_viewport_or_footer() { + assert_eq!( + browser_panel_areas(Rect::new( + /*x*/ 4, /*y*/ 8, /*width*/ 12, /*height*/ 2, + )), + BrowserPanelAreas { + header: Rect::new( + /*x*/ 4, /*y*/ 8, /*width*/ 12, /*height*/ 2, + ), + viewport: Rect::new( + /*x*/ 4, /*y*/ 10, /*width*/ 12, /*height*/ 0, + ), + footer: Rect::new( + /*x*/ 4, /*y*/ 10, /*width*/ 12, /*height*/ 0, + ), + } + ); +} + +#[test] +#[expect( + clippy::disallowed_methods, + reason = "the assertion verifies exact Carbonyl RGB and indexed color preservation" +)] +fn browser_cell_style_maps_vt_attributes() { + let cell = BrowserCell { + text: "x".to_string(), + foreground: BrowserColor::Rgb(1, 2, 3), + background: BrowserColor::Indexed(4), + bold: true, + dim: false, + italic: true, + underlined: true, + reversed: false, + wide_continuation: false, + }; + + assert_eq!( + style_for_test(&cell), + Style::default() + .fg(Color::Rgb(1, 2, 3)) + .bg(Color::Indexed(4)) + .underline_color(Color::Reset) + .bold() + .italic() + .underlined() + ); +} + +#[test] +#[expect( + clippy::disallowed_methods, + reason = "the assertion verifies the clipped browser cell keeps its exact terminal background" +)] +fn cropped_wide_glyph_is_replaced_with_a_blank_cell() { + let mut wide = cell("\u{754c}"); + wide.background = BrowserColor::Indexed(4); + let mut continuation = cell(""); + continuation.wide_continuation = true; + let view = BrowserView { + status: BrowserStatus::Running, + title: None, + url: None, + visible: true, + human_control: false, + screen: BrowserScreen { + rows: 1, + cols: 2, + cells: vec![wide, continuation], + cursor: None, + }, + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + + render_screen_for_test(&view, area, &mut buffer); + + assert_eq!(buffer[(0, 0)].symbol(), " "); + assert_eq!(buffer[(0, 0)].bg, Color::Indexed(4)); +} + +#[test] +fn dynamic_tools_are_namespaced_and_deferred() { + let specs = dynamic_tool_specs(); + let [DynamicToolSpec::Namespace(namespace)] = specs.as_slice() else { + panic!("expected one terminal-browser namespace"); + }; + assert_eq!(namespace.name, TERMINAL_BROWSER_NAMESPACE); + let tools = namespace + .tools + .iter() + .map(|DynamicToolNamespaceTool::Function(tool)| tool) + .collect::>(); + assert_eq!( + tools + .iter() + .map(|spec| spec.name.as_str()) + .collect::>(), + vec![ + "open", + "navigate", + "wait", + "profile", + "snapshot", + "click", + "fill", + "press", + "scroll", + "screenshot", + "set_visibility", + "close", + ] + ); + assert!(tools.iter().all(|spec| spec.defer_loading)); + for tool_name in ["click", "fill"] { + let node_id_pattern = tools + .iter() + .find(|spec| spec.name == tool_name) + .and_then(|spec| spec.input_schema.pointer("/properties/nodeId/pattern")) + .and_then(serde_json::Value::as_str); + assert_eq!( + node_id_pattern, + Some("^d[0-9a-f]{16}n[0-9]{1,20}$"), + "{tool_name} must accept the document-scoped node IDs returned by snapshot" + ); + } +} + +#[test] +fn text_tool_output_maps_to_a_successful_dynamic_tool_response() { + assert_eq!( + dynamic_tool_response(Ok(BrowserToolOutput::Text("done".to_string()))), + DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "done".to_string(), + }], + success: true, + } + ); +} + +#[test] +fn tool_errors_are_structured_without_internal_details() { + let response = + dynamic_tool_response(Err(anyhow::anyhow!("CDP failed with password=do-not-leak"))); + let [DynamicToolCallOutputContentItem::InputText { text }] = response.content_items.as_slice() + else { + panic!("expected one text error"); + }; + let error: serde_json::Value = serde_json::from_str(text).expect("structured error JSON"); + + assert!(!response.success); + assert_eq!(error["error"]["code"], "internal"); + assert!(!text.contains("do-not-leak")); +} + +#[test] +fn terminal_browser_panel_snapshot() { + let view = BrowserView { + status: BrowserStatus::Running, + title: Some("Example".to_string()), + url: Some("https://example.com".to_string()), + visible: true, + human_control: false, + screen: BrowserScreen { + rows: 2, + cols: 4, + cells: vec![ + cell("C"), + cell("o"), + cell("d"), + cell("e"), + cell("x"), + cell(" "), + cell("U"), + cell("I"), + ], + cursor: Some((1, 0)), + }, + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10, + ); + let mut buffer = Buffer::empty(area); + + let viewport = render_view_for_test(&view, area, &mut buffer); + + assert_eq!( + viewport, + Rect::new( + /*x*/ 0, /*y*/ 2, /*width*/ 48, /*height*/ 7, + ) + ); + assert_snapshot!(buffer_text(&buffer, area)); +} + +#[test] +fn terminal_browser_human_control_panel_snapshot() { + let view = BrowserView { + status: BrowserStatus::Running, + title: Some("Example".to_string()), + url: Some("https://example.com".to_string()), + visible: true, + human_control: true, + screen: BrowserScreen { + rows: 1, + cols: 4, + cells: vec![cell("U"), cell("s"), cell("e"), cell("r")], + cursor: None, + }, + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10, + ); + let mut buffer = Buffer::empty(area); + + render_view_for_test(&view, area, &mut buffer); + + assert_snapshot!(buffer_text(&buffer, area)); +} + +#[test] +fn human_control_maps_keyboard_and_mouse_input() { + assert_eq!( + browser_key_input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)), + Some(BrowserKeyInput { + key: "a".to_string(), + code: "KeyA".to_string(), + text: None, + modifiers: BrowserInputModifiers { + control: true, + ..Default::default() + }, + }) + ); + + assert_eq!( + browser_mouse_input( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 12, + row: 8, + modifiers: KeyModifiers::SHIFT, + }, + Rect::new( + /*x*/ 10, /*y*/ 5, /*width*/ 40, /*height*/ 20, + ), + ), + Some(BrowserMouseInput { + kind: BrowserMouseKind::Down, + button: BrowserMouseButton::Left, + column: 2, + row: 3, + viewport_cols: 40, + viewport_rows: 20, + modifiers: BrowserInputModifiers { + shift: true, + ..Default::default() + }, + }) + ); +} + +#[test] +fn mouse_events_outside_the_browser_viewport_are_ignored() { + assert_eq!( + browser_mouse_input( + MouseEvent { + kind: MouseEventKind::Moved, + column: 9, + row: 8, + modifiers: KeyModifiers::NONE, + }, + Rect::new( + /*x*/ 10, /*y*/ 5, /*width*/ 40, /*height*/ 20, + ), + ), + None + ); +} + +#[test] +fn terminal_browser_unavailable_panel_wraps_reason_snapshot() { + let view = BrowserView { + status: BrowserStatus::Unavailable { + reason: "Carbonyl was not found on PATH; install it or set CODEX_CARBONYL_BINARY" + .to_string(), + }, + title: None, + url: None, + visible: true, + human_control: false, + screen: BrowserScreen::blank(TerminalSize { rows: 1, cols: 1 }), + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10, + ); + let mut buffer = Buffer::empty(area); + + render_view_for_test(&view, area, &mut buffer); + + assert_snapshot!(buffer_text(&buffer, area)); +} + +#[test] +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())), + AppEventSender::new(tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 64, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + + view.render(area, &mut buffer); + + assert_snapshot!(buffer_text(&buffer, area)); +} + +#[test] +fn valid_model_profile_mutations_route_to_explicit_approval() { + assert_eq!( + requested_profile_command(&serde_json::json!({ + "action": "requestForget", + "name": "work", + })), + Some(TerminalBrowserProfileCommand::Forget("work".to_string())) + ); + assert_eq!( + requested_profile_command(&serde_json::json!({ + "action": "requestCreate", + "name": "../unsafe", + })), + None + ); +} + +fn cell(text: &str) -> BrowserCell { + BrowserCell { + text: text.to_string(), + foreground: BrowserColor::Default, + background: BrowserColor::Default, + bold: false, + dim: false, + italic: false, + underlined: false, + reversed: false, + wide_continuation: false, + } +} + +fn buffer_text(buffer: &Buffer, area: Rect) -> String { + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} diff --git a/codex-rs/tui/src/terminal_browser/tools.rs b/codex-rs/tui/src/terminal_browser/tools.rs new file mode 100644 index 0000000000..25304ec024 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/tools.rs @@ -0,0 +1,259 @@ +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::DynamicToolFunctionSpec; +use codex_app_server_protocol::DynamicToolNamespaceSpec; +use codex_app_server_protocol::DynamicToolNamespaceTool; +use codex_app_server_protocol::DynamicToolSpec; +use codex_terminal_browser::BrowserToolOutput; +use codex_terminal_browser::classify_browser_error; +use serde_json::json; + +pub(crate) const TERMINAL_BROWSER_NAMESPACE: &str = "terminal_browser"; + +pub(crate) fn dynamic_tool_specs() -> Vec { + vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec { + name: TERMINAL_BROWSER_NAMESPACE.to_string(), + description: "Control a browser rendered inside the terminal.".to_string(), + tools: vec![ + tool( + "open", + "Open a URL in the terminal browser. The browser panel is watch-only; Codex controls the page.", + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "maxLength": 4096, + "description": "HTTP or HTTPS URL to open." + }, + "visible": { "type": "boolean", "description": "Whether to show the browser panel." }, + "renderMode": { + "type": "string", + "enum": ["nativeText", "bitmap"], + "description": "Use native terminal text by default, or bitmap mode for page screenshots." + } + }, + "required": ["url"], + "additionalProperties": false + }), + ), + tool( + "navigate", + "Navigate the current tab with a URL, browser history, or reload and wait for a bounded load state.", + json!({ + "type": "object", + "properties": { + "action": { "type": "string", "enum": ["goto", "back", "forward", "reload"] }, + "url": { + "type": "string", + "maxLength": 4096, + "description": "Required only for goto; must use HTTP or HTTPS." + }, + "waitUntil": { + "type": "string", + "enum": ["domContentLoaded", "load"], + "default": "load" + }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 30000 } + }, + "required": ["action"], + "additionalProperties": false + }), + ), + tool( + "wait", + "Wait up to 30 seconds for a URL, load state, page text, or snapshot node condition.", + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "url" }, + "value": { "type": "string", "maxLength": 4096 }, + "match": { "type": "string", "enum": ["exact", "contains"], "default": "exact" }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 30000 } + }, + "required": ["type", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "const": "loadState" }, + "state": { "type": "string", "enum": ["domContentLoaded", "load"] }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 30000 } + }, + "required": ["type", "state"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "const": "text" }, + "value": { "type": "string", "maxLength": 4096 }, + "state": { "type": "string", "enum": ["present", "absent"] }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 30000 } + }, + "required": ["type", "value", "state"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "const": "node" }, + "nodeId": { "type": "string", "pattern": "^d[0-9a-f]{16}n[0-9]{1,20}$" }, + "state": { "type": "string", "enum": ["visible", "hidden", "attached", "detached"] }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 30000 } + }, + "required": ["type", "nodeId", "state"], + "additionalProperties": false + } + ] + }), + ), + tool( + "profile", + "List profiles or request a user-approved create, select, ephemeral, or forget operation.", + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "requestCreate", "requestSelect", "requestEphemeral", "requestForget"] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + } + }, + "required": ["action"], + "additionalProperties": false + }), + ), + tool( + "snapshot", + "Return a bounded accessibility snapshot of the current page with document-scoped node IDs.", + empty_schema(), + ), + tool( + "click", + "Click a node from the latest terminal browser snapshot.", + json!({ + "type": "object", + "properties": { + "nodeId": { "type": "string", "pattern": "^d[0-9a-f]{16}n[0-9]{1,20}$" } + }, + "required": ["nodeId"], + "additionalProperties": false + }), + ), + tool( + "fill", + "Replace the value of an editable node from the latest terminal browser snapshot.", + json!({ + "type": "object", + "properties": { + "nodeId": { "type": "string", "pattern": "^d[0-9a-f]{16}n[0-9]{1,20}$" }, + "text": { "type": "string", "maxLength": 65536 } + }, + "required": ["nodeId", "text"], + "additionalProperties": false + }), + ), + tool( + "press", + "Send a key press to the active page, for example Enter, Tab, or Escape.", + json!({ + "type": "object", + "properties": { + "key": { "type": "string", "minLength": 1, "maxLength": 32 } + }, + "required": ["key"], + "additionalProperties": false + }), + ), + tool( + "scroll", + "Scroll the active page by CSS pixel deltas.", + json!({ + "type": "object", + "properties": { + "deltaX": { "type": "integer" }, + "deltaY": { "type": "integer" } + }, + "required": ["deltaY"], + "additionalProperties": false + }), + ), + tool( + "screenshot", + "Capture the current page as an image.", + empty_schema(), + ), + tool( + "set_visibility", + "Show or hide the terminal browser panel without stopping browser automation.", + json!({ + "type": "object", + "properties": { "visible": { "type": "boolean" } }, + "required": ["visible"], + "additionalProperties": false + }), + ), + tool( + "close", + "Close the terminal browser. Ephemeral profile data is discarded; named profiles are retained.", + empty_schema(), + ), + ], + })] +} + +pub(crate) fn dynamic_tool_response( + result: anyhow::Result, +) -> DynamicToolCallResponse { + match result { + Ok(BrowserToolOutput::Text(text)) => DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { text }], + success: true, + }, + Ok(BrowserToolOutput::ImageDataUrl(image_url)) => DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputImage { image_url }], + success: true, + }, + Err(err) => DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: serde_json::to_string(&json!({ + "error": classify_browser_error(&err), + })) + .unwrap_or_else(|_| { + "{\"error\":{\"code\":\"internal\",\"message\":\"the terminal-browser action failed\",\"retryable\":false}}".to_string() + }), + }], + success: false, + }, + } +} + +fn tool( + name: &str, + description: &str, + input_schema: serde_json::Value, +) -> DynamicToolNamespaceTool { + DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec { + name: name.to_string(), + description: description.to_string(), + input_schema, + defer_loading: true, + }) +} + +fn empty_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) +}