mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
feat(terminal-browser): add codex-owned chrome
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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 │ "
|
||||
|
||||
+2
-2
@@ -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 "
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
-2
@@ -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
-2
@@ -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
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user