feat(tui): add carbonyl browser panel adapter

This commit is contained in:
Felipe Coury
2026-07-05 20:50:52 -03:00
parent 8e878f6871
commit a80dffef64
11 changed files with 1308 additions and 0 deletions

View File

@@ -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 }

View File

@@ -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<BrowserKeyInput> {
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<BrowserMouseInput> {
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),
}
}

View File

@@ -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;

View File

@@ -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<TerminalBrowser>,
}
impl TerminalBrowserPanel {
pub(crate) fn new(browser: Arc<TerminalBrowser>) -> 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::<Vec<_>>();
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<Color> {
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);
}

View File

@@ -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<TerminalBrowserProfileCommand> {
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<SelectionAction> = 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()
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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::<Vec<_>>();
assert_eq!(
tools
.iter()
.map(|spec| spec.name.as_str())
.collect::<Vec<_>>(),
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::<AppEvent>();
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::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}

View File

@@ -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<DynamicToolSpec> {
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<BrowserToolOutput>,
) -> 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
})
}