mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
feat(tui): add carbonyl browser panel adapter
This commit is contained in:
@@ -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 }
|
||||
|
||||
141
codex-rs/tui/src/terminal_browser/input.rs
Normal file
141
codex-rs/tui/src/terminal_browser/input.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
26
codex-rs/tui/src/terminal_browser/mod.rs
Normal file
26
codex-rs/tui/src/terminal_browser/mod.rs
Normal 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;
|
||||
285
codex-rs/tui/src/terminal_browser/panel.rs
Normal file
285
codex-rs/tui/src/terminal_browser/panel.rs
Normal 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);
|
||||
}
|
||||
97
codex-rs/tui/src/terminal_browser/profile_approval.rs
Normal file
97
codex-rs/tui/src/terminal_browser/profile_approval.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
441
codex-rs/tui/src/terminal_browser/tests.rs
Normal file
441
codex-rs/tui/src/terminal_browser/tests.rs
Normal 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")
|
||||
}
|
||||
259
codex-rs/tui/src/terminal_browser/tools.rs
Normal file
259
codex-rs/tui/src/terminal_browser/tools.rs
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user