feat(terminal-browser): add codex-owned chrome

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