feat: add browser control tools with automatic screenshot feedback

- Add browser_click, browser_type, browser_key, browser_javascript tools
- Implement automatic screenshot capture after browser actions
- Fix JavaScript execution bug where code was not being executed
- Add comprehensive logging for JavaScript results
- Format JavaScript results with console logs and return values
- Include ephemeral screenshots in tool responses for immediate LLM feedback
- Add global browser manager singleton for state sharing
- Fix "runtime within runtime" panic in TUI

The LLM agent can now:
- Control browser with click, type, and key press actions
- Execute JavaScript and see console logs + return values
- Receive visual feedback immediately after each action
- Perform multi-step browser automation workflows

🤖 Generated with Claude Code

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-11 08:59:12 +10:00
co-authored by Claude
parent cc243b1c2b
commit a3939a0b02
25 changed files with 3087 additions and 24 deletions
+246 -4
View File
@@ -283,6 +283,20 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "async-tungstenite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5359381fd414fbdb272c48f2111c16cb0bb3447bfacd59311ff3736da9f6664"
dependencies = [
"futures-io",
"futures-util",
"log",
"pin-project-lite",
"tokio",
"tungstenite",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -503,6 +517,72 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chromiumoxide"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f"
dependencies = [
"async-tungstenite",
"base64 0.22.1",
"cfg-if",
"chromiumoxide_cdp",
"chromiumoxide_types",
"dunce",
"fnv",
"futures",
"futures-timer",
"pin-project-lite",
"reqwest",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tracing",
"url",
"which",
"winreg",
]
[[package]]
name = "chromiumoxide_cdp"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cadbfb52fa0aeca43626f6c42ca04184b108b786f8e45198dc41a42aedcf2e50"
dependencies = [
"chromiumoxide_pdl",
"chromiumoxide_types",
"serde",
"serde_json",
]
[[package]]
name = "chromiumoxide_pdl"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c197aeb42872c5d4c923e7d8ad46d99a58fd0fec37f6491554ff677a6791d3c9"
dependencies = [
"chromiumoxide_types",
"either",
"heck 0.4.1",
"once_cell",
"proc-macro2",
"quote",
"regex",
"serde",
"serde_json",
]
[[package]]
name = "chromiumoxide_types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923486888790528d55ac37ec2f7483ed19eb8ccbb44701878e5856d1ceadf5d8"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "chrono"
version = "0.4.41"
@@ -556,7 +636,7 @@ version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -617,6 +697,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-browser"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"bytes",
"chromiumoxide",
"chrono",
"futures",
"image",
"once_cell",
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.12",
"tokio",
"tokio-test",
"tracing",
"uuid",
]
[[package]]
name = "codex-chatgpt"
version = "0.0.0"
@@ -675,6 +779,7 @@ dependencies = [
"bytes",
"chrono",
"codex-apply-patch",
"codex-browser",
"codex-login",
"codex-mcp-client",
"core_test_support",
@@ -870,6 +975,7 @@ dependencies = [
"clap",
"codex-ansi-escape",
"codex-arg0",
"codex-browser",
"codex-common",
"codex-core",
"codex-file-search",
@@ -1155,6 +1261,12 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deadpool"
version = "0.10.0"
@@ -1354,6 +1466,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dupe"
version = "0.9.1"
@@ -1907,6 +2025,12 @@ dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -4394,7 +4518,7 @@ version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"rustversion",
@@ -4407,7 +4531,7 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -4520,7 +4644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"heck",
"heck 0.5.0",
"pkg-config",
"toml 0.8.23",
"version-compare",
@@ -5069,6 +5193,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.18.0"
@@ -5146,6 +5288,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5375,6 +5523,18 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]]
name = "which"
version = "6.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
dependencies = [
"either",
"home",
"rustix 0.38.44",
"winsafe",
]
[[package]]
name = "whoami"
version = "1.6.0"
@@ -5557,6 +5717,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -5584,6 +5753,21 @@ dependencies = [
"windows-targets 0.53.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5616,6 +5800,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5628,6 +5818,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5640,6 +5836,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5664,6 +5866,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5676,6 +5884,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5688,6 +5902,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5700,6 +5920,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5721,6 +5947,22 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wiremock"
version = "0.6.4"
+1
View File
@@ -3,6 +3,7 @@ members = [
"ansi-escape",
"apply-patch",
"arg0",
"browser",
"cli",
"common",
"core",
+33
View File
@@ -0,0 +1,33 @@
[package]
edition = "2024"
name = "codex-browser"
version = { workspace = true }
[lib]
name = "codex_browser"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = "1"
async-trait = "0.1"
base64 = "0.22"
bytes = "1"
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false }
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
image = "0.25"
once_cell = "1.20"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
thiserror = "2"
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
tracing = "0.1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tokio-test = "0.4"
+138
View File
@@ -0,0 +1,138 @@
use crate::{config::ImageFormat, Result};
use std::path::{Path, PathBuf};
use tokio::fs;
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
use std::collections::HashMap;
use tokio::sync::RwLock;
use std::sync::Arc;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageRef {
pub path: String,
pub mime: String,
pub width: u32,
pub height: u32,
pub ttl_ms: u64,
pub created_at: DateTime<Utc>,
}
pub struct AssetManager {
base_dir: PathBuf,
#[allow(dead_code)]
session_id: String,
assets: Arc<RwLock<HashMap<String, ImageRef>>>,
}
impl AssetManager {
pub async fn new() -> Result<Self> {
let session_id = Uuid::new_v4().to_string();
let base_dir = PathBuf::from("/tmp/codex/browser").join(&session_id);
fs::create_dir_all(&base_dir).await?;
Ok(Self {
base_dir,
session_id,
assets: Arc::new(RwLock::new(HashMap::new())),
})
}
pub async fn store_screenshot(
&self,
data: &[u8],
format: ImageFormat,
width: u32,
height: u32,
ttl_ms: u64,
) -> Result<ImageRef> {
let filename = format!("{}.{}", Uuid::new_v4(), match format {
ImageFormat::Png => "png",
ImageFormat::Webp => "webp",
});
let path = self.base_dir.join(&filename);
fs::write(&path, data).await?;
let mime = match format {
ImageFormat::Png => "image/png",
ImageFormat::Webp => "image/webp",
}.to_string();
let image_ref = ImageRef {
path: path.to_string_lossy().to_string(),
mime,
width,
height,
ttl_ms,
created_at: Utc::now(),
};
let mut assets = self.assets.write().await;
assets.insert(filename, image_ref.clone());
Ok(image_ref)
}
pub async fn store_screenshots(
&self,
screenshots: Vec<crate::page::Screenshot>,
ttl_ms: u64,
) -> Result<Vec<ImageRef>> {
let mut refs = Vec::new();
for screenshot in screenshots {
let image_ref = self.store_screenshot(
&screenshot.data,
screenshot.format,
screenshot.width,
screenshot.height,
ttl_ms,
).await?;
refs.push(image_ref);
}
Ok(refs)
}
pub async fn cleanup_expired(&self) -> Result<()> {
let now = Utc::now();
let mut assets = self.assets.write().await;
let mut to_remove = Vec::new();
for (key, asset) in assets.iter() {
let age = now - asset.created_at;
if age > Duration::milliseconds(asset.ttl_ms as i64) {
to_remove.push(key.clone());
let _ = fs::remove_file(&asset.path).await;
}
}
for key in to_remove {
assets.remove(&key);
}
Ok(())
}
pub async fn cleanup_all(&self) -> Result<()> {
if self.base_dir.exists() {
fs::remove_dir_all(&self.base_dir).await?;
}
Ok(())
}
pub fn get_session_dir(&self) -> &Path {
&self.base_dir
}
}
impl Drop for AssetManager {
fn drop(&mut self) {
let base_dir = self.base_dir.clone();
tokio::spawn(async move {
let _ = fs::remove_dir_all(&base_dir).await;
});
}
}
+94
View File
@@ -0,0 +1,94 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_viewport")]
pub viewport: ViewportConfig,
#[serde(default = "default_wait")]
pub wait: WaitStrategy,
#[serde(default)]
pub fullpage: bool,
#[serde(default = "default_segments_max")]
pub segments_max: usize,
#[serde(default = "default_idle_timeout_ms")]
pub idle_timeout_ms: u64,
#[serde(default = "default_format")]
pub format: ImageFormat,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
viewport: default_viewport(),
wait: default_wait(),
fullpage: false,
segments_max: default_segments_max(),
idle_timeout_ms: default_idle_timeout_ms(),
format: default_format(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportConfig {
pub width: u32,
pub height: u32,
#[serde(default = "default_device_scale_factor")]
pub device_scale_factor: f64,
#[serde(default)]
pub mobile: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WaitStrategy {
Event(String),
Delay { delay_ms: u64 },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Png,
Webp,
}
fn default_viewport() -> ViewportConfig {
ViewportConfig {
width: 1024,
height: 768,
device_scale_factor: 1.0,
mobile: false,
}
}
fn default_wait() -> WaitStrategy {
WaitStrategy::Event("networkidle".to_string())
}
fn default_segments_max() -> usize {
8
}
fn default_idle_timeout_ms() -> u64 {
60000
}
fn default_device_scale_factor() -> f64 {
1.0
}
fn default_format() -> ImageFormat {
ImageFormat::Png
}
+34
View File
@@ -0,0 +1,34 @@
use crate::manager::BrowserManager;
use crate::config::BrowserConfig;
use std::sync::Arc;
use once_cell::sync::Lazy;
use tokio::sync::RwLock;
/// Global browser manager instance shared between TUI and Session
static GLOBAL_BROWSER_MANAGER: Lazy<Arc<RwLock<Option<Arc<BrowserManager>>>>> =
Lazy::new(|| Arc::new(RwLock::new(None)));
/// Get or create the global browser manager
pub async fn get_or_create_browser_manager() -> Arc<BrowserManager> {
let mut guard = GLOBAL_BROWSER_MANAGER.write().await;
if let Some(manager) = guard.as_ref() {
manager.clone()
} else {
let mut config = BrowserConfig::default();
config.enabled = true;
let manager = Arc::new(BrowserManager::new(config));
manager.set_enabled_sync(true);
*guard = Some(manager.clone());
manager
}
}
/// Get the global browser manager if it exists
pub async fn get_browser_manager() -> Option<Arc<BrowserManager>> {
GLOBAL_BROWSER_MANAGER.read().await.clone()
}
/// Clear the global browser manager
pub async fn clear_browser_manager() {
*GLOBAL_BROWSER_MANAGER.write().await = None;
}
@@ -0,0 +1,90 @@
use crate::{
assets::{AssetManager, ImageRef},
manager::BrowserManager,
page::ScreenshotMode,
Result,
};
use std::sync::Arc;
use tracing::{debug, info};
pub struct BrowserInjector {
manager: Arc<BrowserManager>,
asset_manager: Arc<AssetManager>,
}
impl BrowserInjector {
pub fn new(manager: Arc<BrowserManager>, asset_manager: Arc<AssetManager>) -> Self {
Self {
manager,
asset_manager,
}
}
pub async fn inject_pre_llm_call(&self) -> Result<Option<InjectionResult>> {
if !self.manager.is_enabled().await {
return Ok(None);
}
debug!("Browser enabled, capturing pre-LLM screenshot");
let page = match self.manager.get_or_create_page().await {
Ok(p) => p,
Err(_) => {
info!("No active page, using about:blank");
let page = self.manager.get_or_create_page().await?;
page.goto("about:blank", None).await?;
page
}
};
let config = self.manager.get_config().await;
let mode = if config.fullpage {
ScreenshotMode::FullPage {
segments_max: Some(config.segments_max),
}
} else {
ScreenshotMode::Viewport
};
let screenshots = page.screenshot(mode).await?;
let ttl_ms = 300000;
let images = self.asset_manager.store_screenshots(screenshots, ttl_ms).await?;
let current_url = page.get_url().await.unwrap_or_else(|_| "about:blank".to_string());
let system_hint = format!(
"A fresh screenshot of the active page ({}) is attached; use browser.* tools to navigate or capture more.",
current_url
);
let segments_captured = images.len();
Ok(Some(InjectionResult {
images,
system_hint,
metadata: InjectionMetadata {
url: current_url,
fullpage: config.fullpage,
segments_captured,
},
}))
}
pub async fn cleanup_expired(&self) -> Result<()> {
self.asset_manager.cleanup_expired().await
}
}
#[derive(Debug)]
pub struct InjectionResult {
pub images: Vec<ImageRef>,
pub system_hint: String,
pub metadata: InjectionMetadata,
}
#[derive(Debug)]
pub struct InjectionMetadata {
pub url: String,
pub fullpage: bool,
pub segments_captured: usize,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod browser_injector;
pub use browser_injector::{BrowserInjector, InjectionResult};
+45
View File
@@ -0,0 +1,45 @@
pub mod assets;
pub mod config;
pub mod global;
pub mod hooks;
pub mod manager;
pub mod page;
pub mod tools;
pub use config::{BrowserConfig, ViewportConfig, WaitStrategy};
pub use manager::BrowserManager;
pub use page::{Page, ScreenshotMode, ScreenshotRegion};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BrowserError {
#[error("Browser not initialized")]
NotInitialized,
#[error("Page not loaded")]
PageNotLoaded,
#[error("CDP error: {0}")]
CdpError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Screenshot failed: {0}")]
ScreenshotError(String),
#[error("Invalid configuration: {0}")]
ConfigError(String),
#[error("Asset storage error: {0}")]
AssetError(String),
}
impl From<chromiumoxide::error::CdpError> for BrowserError {
fn from(e: chromiumoxide::error::CdpError) -> Self {
BrowserError::CdpError(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, BrowserError>;
+412
View File
@@ -0,0 +1,412 @@
use crate::{config::BrowserConfig, page::Page, BrowserError, Result};
use chromiumoxide::{Browser, BrowserConfig as CdpConfig};
use futures::StreamExt;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tokio::time::{sleep, Duration, Instant};
use tracing::{debug, info, warn};
pub struct BrowserManager {
pub config: Arc<RwLock<BrowserConfig>>,
browser: Arc<Mutex<Option<Browser>>>,
page: Arc<Mutex<Option<Arc<Page>>>>,
last_activity: Arc<Mutex<Instant>>,
idle_monitor_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
assets: Arc<Mutex<Option<Arc<crate::assets::AssetManager>>>>,
}
impl BrowserManager {
pub fn new(config: BrowserConfig) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
browser: Arc::new(Mutex::new(None)),
page: Arc::new(Mutex::new(None)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_monitor_handle: Arc::new(Mutex::new(None)),
assets: Arc::new(Mutex::new(None)),
}
}
pub async fn start(&self) -> Result<()> {
let mut browser_guard = self.browser.lock().await;
if browser_guard.is_some() {
return Ok(());
}
info!("Starting browser");
let (browser, mut handler) = Browser::launch(
CdpConfig::builder()
.window_size(1024, 768)
.build()
.map_err(|e| BrowserError::CdpError(e.to_string()))?
)
.await?;
let _handle = tokio::spawn(async move {
while let Some(event) = handler.next().await {
debug!("Browser event: {:?}", event);
}
});
*browser_guard = Some(browser);
self.start_idle_monitor().await;
self.update_activity().await;
Ok(())
}
pub async fn stop(&self) -> Result<()> {
self.stop_idle_monitor().await;
let mut page_guard = self.page.lock().await;
*page_guard = None;
let mut browser_guard = self.browser.lock().await;
if let Some(mut browser) = browser_guard.take() {
info!("Stopping browser");
browser.close().await?;
}
Ok(())
}
pub async fn get_or_create_page(&self) -> Result<Arc<Page>> {
self.ensure_browser().await?;
self.update_activity().await;
let mut page_guard = self.page.lock().await;
if let Some(page) = page_guard.as_ref() {
return Ok(Arc::clone(page));
}
let browser_guard = self.browser.lock().await;
let browser = browser_guard
.as_ref()
.ok_or(BrowserError::NotInitialized)?;
let cdp_page = browser.new_page("about:blank").await?;
let config = self.config.read().await;
// Set viewport dimensions using CDP protocol
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
let params = SetDeviceMetricsOverrideParams::builder()
.width(config.viewport.width as i64)
.height(config.viewport.height as i64)
.device_scale_factor(config.viewport.device_scale_factor)
.mobile(config.viewport.mobile)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
cdp_page.execute(params).await?;
let page = Arc::new(Page::new(cdp_page, config.clone()));
*page_guard = Some(Arc::clone(&page));
Ok(page)
}
pub async fn close_page(&self) -> Result<()> {
let mut page_guard = self.page.lock().await;
if let Some(page) = page_guard.take() {
page.close().await?;
}
Ok(())
}
pub async fn is_enabled(&self) -> bool {
self.config.read().await.enabled
}
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
let mut config = self.config.write().await;
config.enabled = enabled;
if enabled {
self.start().await?;
} else {
self.stop().await?;
}
Ok(())
}
pub async fn update_config(&self, updates: impl FnOnce(&mut BrowserConfig)) -> Result<()> {
let mut config = self.config.write().await;
updates(&mut config);
if let Some(page) = self.page.lock().await.as_ref() {
page.update_viewport(config.viewport.clone()).await?;
}
Ok(())
}
pub async fn get_config(&self) -> BrowserConfig {
self.config.read().await.clone()
}
pub async fn get_status(&self) -> BrowserStatus {
let config = self.config.read().await;
let browser_active = self.browser.lock().await.is_some();
let page_guard = self.page.lock().await;
let current_url = if let Some(page) = page_guard.as_ref() {
page.get_url().await.ok()
} else {
None
};
BrowserStatus {
enabled: config.enabled,
browser_active,
current_url,
viewport: config.viewport.clone(),
fullpage: config.fullpage,
}
}
async fn ensure_browser(&self) -> Result<()> {
let browser_guard = self.browser.lock().await;
if browser_guard.is_none() {
drop(browser_guard);
self.start().await?;
}
Ok(())
}
async fn update_activity(&self) {
let mut last_activity = self.last_activity.lock().await;
*last_activity = Instant::now();
}
pub fn set_enabled_sync(&self, enabled: bool) {
// Try to set immediately if possible, otherwise spawn a task
if let Ok(mut cfg) = self.config.try_write() {
cfg.enabled = enabled;
} else {
let config = self.config.clone();
tokio::spawn(async move {
let mut cfg = config.write().await;
cfg.enabled = enabled;
});
}
}
pub fn set_fullpage_sync(&self, fullpage: bool) {
if let Ok(mut cfg) = self.config.try_write() {
cfg.fullpage = fullpage;
} else {
let config = self.config.clone();
tokio::spawn(async move {
let mut cfg = config.write().await;
cfg.fullpage = fullpage;
});
}
}
pub fn set_viewport_sync(&self, width: u32, height: u32) {
if let Ok(mut cfg) = self.config.try_write() {
cfg.viewport.width = width;
cfg.viewport.height = height;
} else {
let config = self.config.clone();
tokio::spawn(async move {
let mut cfg = config.write().await;
cfg.viewport.width = width;
cfg.viewport.height = height;
});
}
}
pub fn set_segments_max_sync(&self, segments_max: usize) {
if let Ok(mut cfg) = self.config.try_write() {
cfg.segments_max = segments_max;
} else {
let config = self.config.clone();
tokio::spawn(async move {
let mut cfg = config.write().await;
cfg.segments_max = segments_max;
});
}
}
pub fn get_status_sync(&self) -> String {
// Use try operations to avoid blocking - return cached/default values if locks are held
let cfg = self.config.try_read().map(|c| {
let enabled = c.enabled;
let viewport_width = c.viewport.width;
let viewport_height = c.viewport.height;
let fullpage = c.fullpage;
(enabled, viewport_width, viewport_height, fullpage)
}).unwrap_or((false, 1024, 768, false));
let browser_active = self.browser.try_lock().map(|b| b.is_some()).unwrap_or(false);
let mode = if cfg.0 { "enabled" } else { "disabled" };
let fullpage = if cfg.3 { "on" } else { "off" };
let mut status = format!(
"Browser status:\n• Mode: {}\n• Viewport: {}×{}\n• Full-page: {}",
mode, cfg.1, cfg.2, fullpage
);
if browser_active {
status.push_str("\n• Browser: active");
}
status
}
async fn start_idle_monitor(&self) {
let config = self.config.read().await;
let idle_timeout = Duration::from_millis(config.idle_timeout_ms);
drop(config);
let browser = Arc::clone(&self.browser);
let last_activity = Arc::clone(&self.last_activity);
let handle = tokio::spawn(async move {
loop {
sleep(Duration::from_secs(10)).await;
let last = *last_activity.lock().await;
if last.elapsed() > idle_timeout {
warn!("Browser idle timeout reached, closing");
let mut browser_guard = browser.lock().await;
if let Some(mut browser) = browser_guard.take() {
let _ = browser.close().await;
}
break;
}
}
});
let mut handle_guard = self.idle_monitor_handle.lock().await;
*handle_guard = Some(handle);
}
async fn stop_idle_monitor(&self) {
let mut handle_guard = self.idle_monitor_handle.lock().await;
if let Some(handle) = handle_guard.take() {
handle.abort();
}
}
pub async fn goto(&self, url: &str) -> Result<crate::page::GotoResult> {
// Get or create page
let page = self.get_or_create_page().await?;
let config = self.config.read().await;
let result = page.goto(url, Some(config.wait.clone())).await?;
self.update_activity().await;
Ok(result)
}
pub async fn capture_screenshot_with_url(&self) -> Result<(Vec<std::path::PathBuf>, Option<String>)> {
let (paths, url) = self.capture_screenshot_internal().await?;
Ok((paths, Some(url)))
}
pub async fn capture_screenshot(&self) -> Result<Vec<std::path::PathBuf>> {
let (paths, _) = self.capture_screenshot_internal().await?;
Ok(paths)
}
async fn capture_screenshot_internal(&self) -> Result<(Vec<std::path::PathBuf>, String)> {
// Ensure we have a browser and page
let page = self.get_or_create_page().await?;
// Initialize assets manager if needed
let mut assets_guard = self.assets.lock().await;
if assets_guard.is_none() {
*assets_guard = Some(Arc::new(crate::assets::AssetManager::new().await?));
}
let assets = assets_guard.as_ref().unwrap().clone();
drop(assets_guard);
// Get current config
let config = self.config.read().await;
// Determine screenshot mode
let mode = if config.fullpage {
crate::page::ScreenshotMode::FullPage {
segments_max: Some(config.segments_max)
}
} else {
crate::page::ScreenshotMode::Viewport
};
// Get current URL
let current_url = page.get_url().await.unwrap_or_else(|_| "about:blank".to_string());
// Capture screenshots
let screenshots = page.screenshot(mode).await?;
// Store screenshots and get paths
let mut paths = Vec::new();
for screenshot in screenshots {
let image_ref = assets.store_screenshot(
&screenshot.data,
screenshot.format,
screenshot.width,
screenshot.height,
300000, // 5 minute TTL
).await?;
paths.push(std::path::PathBuf::from(image_ref.path));
}
self.update_activity().await;
Ok((paths, current_url))
}
pub async fn close(&self) -> Result<()> {
self.stop_idle_monitor().await;
let mut page_guard = self.page.lock().await;
if let Some(page) = page_guard.take() {
page.close().await?;
}
let mut browser_guard = self.browser.lock().await;
if let Some(mut browser) = browser_guard.take() {
browser.close().await?;
}
Ok(())
}
/// Click at the specified coordinates
pub async fn click(&self, x: f64, y: f64) -> Result<()> {
let page = self.get_or_create_page().await?;
page.click(x, y).await
}
/// Type text into the currently focused element
pub async fn type_text(&self, text: &str) -> Result<()> {
let page = self.get_or_create_page().await?;
page.type_text(text).await
}
/// Press a key (e.g., "Enter", "Tab", "Escape", "ArrowDown")
pub async fn press_key(&self, key: &str) -> Result<()> {
let page = self.get_or_create_page().await?;
page.press_key(key).await
}
/// Execute JavaScript code with enhanced return value handling
pub async fn execute_javascript(&self, code: &str) -> Result<serde_json::Value> {
let page = self.get_or_create_page().await?;
page.execute_javascript(code).await
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BrowserStatus {
pub enabled: bool,
pub browser_active: bool,
pub current_url: Option<String>,
pub viewport: crate::config::ViewportConfig,
pub fullpage: bool,
}
+512
View File
@@ -0,0 +1,512 @@
use crate::{
config::{BrowserConfig, ImageFormat, ViewportConfig, WaitStrategy},
BrowserError, Result,
};
use chromiumoxide::page::Page as CdpPage;
use chromiumoxide::cdp::browser_protocol::page::{CaptureScreenshotFormat, CaptureScreenshotParams};
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType,
DispatchKeyEventParams, DispatchKeyEventType,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
pub struct Page {
cdp_page: Arc<CdpPage>,
config: BrowserConfig,
current_url: Arc<RwLock<Option<String>>>,
}
impl Page {
pub fn new(cdp_page: CdpPage, config: BrowserConfig) -> Self {
Self {
cdp_page: Arc::new(cdp_page),
config,
current_url: Arc::new(RwLock::new(None)),
}
}
pub async fn goto(&self, url: &str, wait: Option<WaitStrategy>) -> Result<GotoResult> {
info!("Navigating to {}", url);
let wait_strategy = wait.unwrap_or_else(|| self.config.wait.clone());
self.cdp_page.goto(url).await?;
match wait_strategy {
WaitStrategy::Event(event) => {
match event.as_str() {
"domcontentloaded" => {
self.cdp_page.wait_for_navigation().await?;
}
"networkidle" | "networkidle0" => {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
"networkidle2" => {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
"load" => {
self.cdp_page.wait_for_navigation().await?;
}
_ => {
return Err(BrowserError::ConfigError(format!(
"Unknown wait event: {}",
event
)));
}
}
}
WaitStrategy::Delay { delay_ms } => {
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
}
let title = self.cdp_page.get_title().await.ok().flatten();
let final_url = self.cdp_page.url().await?.unwrap_or_else(|| url.to_string());
let mut current_url = self.current_url.write().await;
*current_url = Some(final_url.clone());
Ok(GotoResult {
url: final_url,
title,
})
}
pub async fn screenshot(&self, mode: ScreenshotMode) -> Result<Vec<Screenshot>> {
match mode {
ScreenshotMode::Viewport => self.screenshot_viewport().await,
ScreenshotMode::FullPage { segments_max } => {
self.screenshot_fullpage(segments_max.unwrap_or(self.config.segments_max))
.await
}
ScreenshotMode::Region(region) => self.screenshot_region(region).await,
}
}
pub async fn screenshot_viewport(&self) -> Result<Vec<Screenshot>> {
debug!("Taking viewport screenshot");
let format = match self.config.format {
ImageFormat::Png => CaptureScreenshotFormat::Png,
ImageFormat::Webp => CaptureScreenshotFormat::Webp,
};
let params = CaptureScreenshotParams::builder()
.format(format)
.build();
let data = self.cdp_page.screenshot(params).await?;
Ok(vec![Screenshot {
data,
width: self.config.viewport.width,
height: self.config.viewport.height,
format: self.config.format,
}])
}
pub async fn screenshot_fullpage(&self, segments_max: usize) -> Result<Vec<Screenshot>> {
debug!("Taking full page screenshot with max {} segments", segments_max);
let viewport = &self.config.viewport;
let overlap = 24;
let scroll_height = viewport.height - overlap;
let mut screenshots = Vec::new();
let mut current_y = 0;
for segment_idx in 0..segments_max {
self.cdp_page
.evaluate(format!("window.scrollTo(0, {})", current_y))
.await?;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let format = match self.config.format {
ImageFormat::Png => CaptureScreenshotFormat::Png,
ImageFormat::Webp => CaptureScreenshotFormat::Webp,
};
let params = CaptureScreenshotParams::builder()
.format(format)
.build();
let data = self.cdp_page.screenshot(params).await?;
screenshots.push(Screenshot {
data,
width: viewport.width,
height: viewport.height,
format: self.config.format,
});
let eval_result = self
.cdp_page
.evaluate("document.body.scrollHeight")
.await?;
let page_height: i64 = eval_result
.value()
.and_then(|v| v.as_i64())
.unwrap_or(0);
current_y += scroll_height as i64;
if current_y >= page_height {
debug!("Reached end of page after {} segments", segment_idx + 1);
break;
}
}
if screenshots.len() == segments_max {
info!("[full page truncated at {} segments]", segments_max);
}
Ok(screenshots)
}
pub async fn screenshot_region(&self, region: ScreenshotRegion) -> Result<Vec<Screenshot>> {
debug!(
"Taking region screenshot: {}x{} at ({}, {})",
region.width, region.height, region.x, region.y
);
let format = match self.config.format {
ImageFormat::Png => CaptureScreenshotFormat::Png,
ImageFormat::Webp => CaptureScreenshotFormat::Webp,
};
let params = CaptureScreenshotParams::builder()
.format(format)
.clip(chromiumoxide::cdp::browser_protocol::page::Viewport {
x: region.x as f64,
y: region.y as f64,
width: region.width as f64,
height: region.height as f64,
scale: 1.0,
})
.build();
let data = self.cdp_page.screenshot(params).await?;
let final_width = if region.width > 1024 {
1024
} else {
region.width
};
Ok(vec![Screenshot {
data,
width: final_width,
height: region.height,
format: self.config.format,
}])
}
pub async fn set_viewport(&self, viewport: SetViewportParams) -> Result<ViewportResult> {
let params = SetDeviceMetricsOverrideParams::builder()
.width(viewport.width as i64)
.height(viewport.height as i64)
.device_scale_factor(viewport.device_scale_factor.unwrap_or(1.0))
.mobile(viewport.mobile.unwrap_or(false))
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
Ok(ViewportResult {
width: viewport.width,
height: viewport.height,
dpr: viewport.device_scale_factor.unwrap_or(1.0),
})
}
pub async fn inject_js(&self, script: &str) -> Result<serde_json::Value> {
let result = self.cdp_page.evaluate(script).await?;
Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
}
pub async fn close(&self) -> Result<()> {
// Note: chromiumoxide's close() takes ownership, so we can't call it on Arc<Page>
// The page will be closed when the Arc is dropped
Ok(())
}
pub async fn get_url(&self) -> Result<String> {
let url_guard = self.current_url.read().await;
url_guard
.clone()
.ok_or(BrowserError::PageNotLoaded)
}
pub async fn update_viewport(&self, viewport: ViewportConfig) -> Result<()> {
let params = SetDeviceMetricsOverrideParams::builder()
.width(viewport.width as i64)
.height(viewport.height as i64)
.device_scale_factor(viewport.device_scale_factor)
.mobile(viewport.mobile)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
Ok(())
}
/// Click at the specified coordinates
pub async fn click(&self, x: f64, y: f64) -> Result<()> {
debug!("Clicking at ({}, {})", x, y);
// Move to position
let move_params = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(x)
.y(y)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(move_params).await?;
// Mouse down
let down_params = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(chromiumoxide::cdp::browser_protocol::input::MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(down_params).await?;
// Mouse up
let up_params = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(chromiumoxide::cdp::browser_protocol::input::MouseButton::Left)
.click_count(1)
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(up_params).await?;
Ok(())
}
/// Type text into the currently focused element
pub async fn type_text(&self, text: &str) -> Result<()> {
debug!("Typing text: {}", text);
for ch in text.chars() {
let params = DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::Char)
.text(ch.to_string())
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
}
Ok(())
}
/// Press a key (e.g., "Enter", "Tab", "Escape", "ArrowDown")
pub async fn press_key(&self, key: &str) -> Result<()> {
debug!("Pressing key: {}", key);
// Key down
let down_params = DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyDown)
.key(key.to_string())
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(down_params).await?;
// Key up
let up_params = DispatchKeyEventParams::builder()
.r#type(DispatchKeyEventType::KeyUp)
.key(key.to_string())
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(up_params).await?;
Ok(())
}
/// Execute JavaScript code with enhanced return value handling
pub async fn execute_javascript(&self, code: &str) -> Result<serde_json::Value> {
debug!("Executing JavaScript: {}...", &code.chars().take(100).collect::<String>());
// Helper to check if a line looks like an expression that should be returned
let is_expression_line = |line: &str| -> bool {
let line = line.trim();
if line.is_empty() { return false; }
// Skip lines that are already returns or start with control keywords
let control_keywords = [
"return", "const", "let", "var", "class", "function", "async",
"if", "for", "while", "switch", "try", "catch", "finally",
"throw", "import", "export", "yield", "await", "break",
"continue", "debugger"
];
for keyword in &control_keywords {
if line.starts_with(keyword) &&
(line.len() == keyword.len() ||
line.chars().nth(keyword.len()).map_or(false, |c| !c.is_alphanumeric())) {
return false;
}
}
// Skip lines that end with block closures
if line.starts_with('}') { return false; }
true
};
// Analyze the code
let lines: Vec<&str> = code.split('\n').collect();
let mut last_expr_line = None;
let mut has_explicit_return = false;
// Scan for significant lines from the end
for (i, line) in lines.iter().enumerate().rev() {
let line = line.trim();
if line.is_empty() || line.starts_with("//") { continue; }
if line.starts_with("return") {
has_explicit_return = true;
break;
}
if last_expr_line.is_none() && is_expression_line(line) {
last_expr_line = Some(i);
if !line.ends_with(';') && !line.ends_with('}') {
break;
}
}
}
// Build the function body
let body = if has_explicit_return {
code.to_string()
} else if let Some(expr_line_idx) = last_expr_line {
let prefix = lines[..expr_line_idx].join("\n");
let expr_line = lines[expr_line_idx].trim();
let suffix = lines[expr_line_idx + 1..].join("\n");
// Handle object literals and array literals - wrap in parentheses
let wrapped_expr = if expr_line.starts_with('{') || expr_line.starts_with('[') {
format!("({})", expr_line)
} else {
expr_line.to_string()
};
format!("{}\nreturn {};\n{}", prefix, wrapped_expr, suffix)
} else {
// Try to capture the last variable defined
let var_regex = regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=").unwrap();
if let Some(captures) = var_regex.captures(code) {
if let Some(var_name) = captures.get(1) {
format!("{}; return typeof {} !== 'undefined' ? {} : undefined;",
code, var_name.as_str(), var_name.as_str())
} else {
code.to_string()
}
} else {
code.to_string()
}
};
// Wrap in async IIFE with error handling and console.log capture
let wrapped = format!(r#"
(async () => {{
const __logs = [];
const __origLog = console.log;
function safe(arg) {{
if (arg === null || typeof arg !== 'object') return String(arg);
try {{ return JSON.stringify(arg); }} catch {{ return '[Circular]'; }}
}}
console.log = function(...args) {{
__logs.push(args.map(safe).join(' '));
__origLog.apply(console, args);
}};
try {{
const AsyncFunction = Object.getPrototypeOf(async function(){{}}).constructor;
const __userCode = {};
const fn = new AsyncFunction(__userCode);
const value = await fn();
return {{
success: true,
value: (value === undefined && __logs.length > 0) ? __logs[__logs.length-1] : value,
logs: __logs
}};
}} catch (err) {{
return {{
success: false,
error: err.toString() + (err.stack ? '\n' + err.stack : ''),
logs: __logs
}};
}} finally {{
console.log = __origLog;
}}
}})()
"#, serde_json::to_string(&body).unwrap());
tracing::debug!("Executing JavaScript code: {}", code);
tracing::debug!("Wrapped code: {}", wrapped);
let result = self.cdp_page.evaluate(wrapped).await?;
let result_value = result.value().cloned().unwrap_or(serde_json::Value::Null);
tracing::info!("JavaScript execution result: {}", result_value);
Ok(result_value)
}
}
#[derive(Debug, Clone)]
pub enum ScreenshotMode {
Viewport,
FullPage { segments_max: Option<usize> },
Region(ScreenshotRegion),
}
#[derive(Debug, Clone)]
pub struct ScreenshotRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug)]
pub struct Screenshot {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub format: ImageFormat,
}
#[derive(Debug, serde::Serialize)]
pub struct GotoResult {
pub url: String,
pub title: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct SetViewportParams {
pub width: u32,
pub height: u32,
pub device_scale_factor: Option<f64>,
pub mobile: Option<bool>,
}
#[derive(Debug, serde::Serialize)]
pub struct ViewportResult {
pub width: u32,
pub height: u32,
pub dpr: f64,
}
+168
View File
@@ -0,0 +1,168 @@
use crate::{
assets::{AssetManager, ImageRef},
manager::BrowserManager,
page::{ScreenshotMode, ScreenshotRegion, SetViewportParams},
config::WaitStrategy,
Result,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub struct BrowserTools {
manager: Arc<BrowserManager>,
asset_manager: Arc<AssetManager>,
}
impl BrowserTools {
pub fn new(manager: Arc<BrowserManager>, asset_manager: Arc<AssetManager>) -> Self {
Self {
manager,
asset_manager,
}
}
pub async fn handle_tool_call(&self, call: BrowserToolCall) -> Result<BrowserToolResult> {
match call {
BrowserToolCall::Goto { url, wait } => {
let page = self.manager.get_or_create_page().await?;
let result = page.goto(&url, wait).await?;
Ok(BrowserToolResult::Goto(result))
}
BrowserToolCall::Screenshot {
mode,
segments_max,
region,
inject_js,
format: _,
} => {
let page = self.manager.get_or_create_page().await?;
if let Some(script) = inject_js {
page.inject_js(&script).await?;
}
let screenshot_mode = match mode.as_deref() {
Some("full_page") => ScreenshotMode::FullPage { segments_max },
Some("viewport") | None => ScreenshotMode::Viewport,
Some(_) => {
if let Some(r) = region {
ScreenshotMode::Region(ScreenshotRegion {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})
} else {
ScreenshotMode::Viewport
}
}
};
let screenshots = page.screenshot(screenshot_mode).await?;
let ttl_ms = 300000;
let images = self.asset_manager.store_screenshots(screenshots, ttl_ms).await?;
Ok(BrowserToolResult::Screenshot(ScreenshotResult { images }))
}
BrowserToolCall::SetViewport {
width,
height,
device_scale_factor,
mobile,
} => {
let page = self.manager.get_or_create_page().await?;
let result = page
.set_viewport(SetViewportParams {
width,
height,
device_scale_factor,
mobile,
})
.await?;
Ok(BrowserToolResult::SetViewport(result))
}
BrowserToolCall::Close { what } => {
match what.as_deref() {
Some("browser") => {
self.manager.stop().await?;
Ok(BrowserToolResult::Close(CloseResult {
closed: "browser".to_string(),
}))
}
Some("page") | None => {
self.manager.close_page().await?;
Ok(BrowserToolResult::Close(CloseResult {
closed: "page".to_string(),
}))
}
Some(other) => Err(crate::BrowserError::ConfigError(format!(
"Unknown close target: {}",
other
))),
}
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "tool", rename_all = "snake_case")]
pub enum BrowserToolCall {
#[serde(rename = "browser.goto")]
Goto {
url: String,
wait: Option<WaitStrategy>,
},
#[serde(rename = "browser.screenshot")]
Screenshot {
mode: Option<String>,
segments_max: Option<usize>,
region: Option<RegionParams>,
inject_js: Option<String>,
format: Option<String>,
},
#[serde(rename = "browser.setViewport")]
SetViewport {
width: u32,
height: u32,
device_scale_factor: Option<f64>,
mobile: Option<bool>,
},
#[serde(rename = "browser.close")]
Close {
what: Option<String>,
},
}
#[derive(Debug, Deserialize)]
pub struct RegionParams {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum BrowserToolResult {
Goto(crate::page::GotoResult),
Screenshot(ScreenshotResult),
SetViewport(crate::page::ViewportResult),
Close(CloseResult),
}
#[derive(Debug, Serialize)]
pub struct ScreenshotResult {
pub images: Vec<ImageRef>,
}
#[derive(Debug, Serialize)]
pub struct CloseResult {
pub closed: String,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod browser_tools;
pub mod schema;
pub use browser_tools::{BrowserTools, BrowserToolCall, BrowserToolResult};
pub use schema::{get_browser_tools_schema, BrowserToolSchema};
+126
View File
@@ -0,0 +1,126 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserToolSchema {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
pub fn get_browser_tools_schema() -> Vec<BrowserToolSchema> {
vec![
BrowserToolSchema {
name: "browser.goto".to_string(),
description: "Navigate to a URL and wait for the page to load".to_string(),
parameters: json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to navigate to"
},
"wait": {
"oneOf": [
{
"type": "string",
"enum": ["domcontentloaded", "networkidle", "networkidle0", "networkidle2", "load"],
"description": "Wait for a specific event"
},
{
"type": "object",
"properties": {
"delay_ms": {
"type": "number",
"description": "Wait for a specific delay in milliseconds"
}
},
"required": ["delay_ms"]
}
],
"description": "Wait strategy for page load"
}
},
"required": ["url"]
}),
},
BrowserToolSchema {
name: "browser.screenshot".to_string(),
description: "Take a screenshot of the current page".to_string(),
parameters: json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["viewport", "full_page"],
"description": "Screenshot mode (default: viewport)"
},
"segments_max": {
"type": "number",
"description": "Maximum number of segments for full_page mode (default: 8)"
},
"region": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number" },
"height": { "type": "number" }
},
"required": ["x", "y", "width", "height"],
"description": "Optional region to capture"
},
"inject_js": {
"type": "string",
"description": "JavaScript to inject before screenshot"
},
"format": {
"type": "string",
"enum": ["png", "webp"],
"description": "Image format (default: png)"
}
}
}),
},
BrowserToolSchema {
name: "browser.setViewport".to_string(),
description: "Set the browser viewport size".to_string(),
parameters: json!({
"type": "object",
"properties": {
"width": {
"type": "number",
"description": "Viewport width in pixels"
},
"height": {
"type": "number",
"description": "Viewport height in pixels"
},
"device_scale_factor": {
"type": "number",
"description": "Device scale factor (default: 1.0)"
},
"mobile": {
"type": "boolean",
"description": "Enable mobile mode (default: false)"
}
},
"required": ["width", "height"]
}),
},
BrowserToolSchema {
name: "browser.close".to_string(),
description: "Close the page or browser".to_string(),
parameters: json!({
"type": "object",
"properties": {
"what": {
"type": "string",
"enum": ["page", "browser"],
"description": "What to close (default: page)"
}
}
}),
},
]
}
+1
View File
@@ -17,6 +17,7 @@ base64 = "0.22"
bytes = "1.10.1"
chrono = { version = "0.4", features = ["serde"] }
codex-apply-patch = { path = "../apply-patch" }
codex-browser = { path = "../browser" }
codex-login = { path = "../login" }
codex-mcp-client = { path = "../mcp-client" }
dirs = "6"
+637 -4
View File
@@ -12,6 +12,7 @@ use std::time::Duration;
use async_channel::Receiver;
use async_channel::Sender;
use base64::Engine;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
@@ -66,6 +67,8 @@ use crate::models::ShellToolCallParams;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
use crate::plan_tool::handle_update_plan;
use serde_json::Value;
use codex_browser::manager::BrowserManager;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
use crate::protocol::AgentMessageEvent;
@@ -236,6 +239,10 @@ pub(crate) struct Session {
codex_linux_sandbox_exe: Option<PathBuf>,
user_shell: shell::Shell,
show_raw_agent_reasoning: bool,
/// Browser manager for controlling headless browser
browser_manager: Mutex<Option<Arc<BrowserManager>>>,
/// Pending browser screenshots to include in the next model request
pending_browser_screenshots: Mutex<Vec<PathBuf>>,
}
impl Session {
@@ -536,8 +543,87 @@ impl Session {
/// Build the full turn input by concatenating the current conversation
/// history with additional items for this turn.
/// Browser screenshots are filtered out from history to keep them ephemeral.
pub fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
[self.state.lock().unwrap().history.contents(), extra].concat()
let history = self.state.lock().unwrap().history.contents();
// Count images in extra for debugging (we can't distinguish ephemeral at this level anymore)
let images_in_extra = extra.iter().filter(|item| {
if let ResponseItem::Message { content, .. } = item {
content.iter().any(|c| {
matches!(c, crate::models::ContentItem::InputImage { .. })
})
} else {
false
}
}).count();
if images_in_extra > 0 {
tracing::info!("Found {} images in current turn's extra items", images_in_extra);
}
// Filter out browser screenshots from historical messages
// We identify them by the [EPHEMERAL:...] marker that precedes them
let filtered_history: Vec<ResponseItem> = history
.into_iter()
.map(|item| {
if let ResponseItem::Message { id, role, content } = item {
if role == "user" {
// Filter out ephemeral content from user messages
let mut filtered_content: Vec<crate::models::ContentItem> = Vec::new();
let mut skip_next_image = false;
for content_item in content {
match &content_item {
crate::models::ContentItem::InputText { text } if text.starts_with("[EPHEMERAL:") => {
// This is an ephemeral marker, skip it and the next image
skip_next_image = true;
tracing::info!("Filtering out ephemeral marker: {}", text);
}
crate::models::ContentItem::InputImage { .. } if skip_next_image => {
// Skip this image as it follows an ephemeral marker
skip_next_image = false;
tracing::info!("Filtering out ephemeral image from history");
}
_ => {
// Keep everything else
filtered_content.push(content_item);
}
}
}
ResponseItem::Message {
id,
role,
content: filtered_content,
}
} else {
// Keep assistant messages unchanged
ResponseItem::Message { id, role, content }
}
} else {
item
}
})
.collect();
// Concatenate filtered history with current turn's extras (which includes current ephemeral images)
let result = [filtered_history, extra].concat();
// Count total images in result for debugging
let total_images = result.iter().filter(|item| {
if let ResponseItem::Message { content, .. } = item {
content.iter().any(|c| matches!(c, crate::models::ContentItem::InputImage { .. }))
} else {
false
}
}).count();
if total_images > 0 {
tracing::info!("Total images being sent to model: {}", total_images);
}
result
}
/// Returns the input if there was no task running to inject into
@@ -562,6 +648,11 @@ impl Session {
}
}
pub fn add_pending_input(&self, input: ResponseInputItem) {
let mut state = self.state.lock().unwrap();
state.pending_input.push(input);
}
pub async fn call_tool(
&self,
server: &str,
@@ -889,6 +980,8 @@ async fn submission_loop(
disable_response_storage,
user_shell: default_shell,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
browser_manager: Mutex::new(None), // Initialize on-demand when browser tools are used
pending_browser_screenshots: Mutex::new(Vec::new()),
}));
// Patch restored state into the newly created session.
@@ -1094,25 +1187,50 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
return;
}
// Debug logging for ephemeral images
let ephemeral_count = input.iter().filter(|item| {
matches!(item, InputItem::EphemeralImage { .. })
}).count();
if ephemeral_count > 0 {
tracing::info!("Processing {} ephemeral images in user input", ephemeral_count);
}
// Convert input to ResponseInputItem
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
sess.record_conversation_items(&[initial_input_for_turn.clone().into()])
let initial_response_item: ResponseItem = initial_input_for_turn.clone().into();
// Record to history but we'll handle ephemeral images separately
sess.record_conversation_items(&[initial_response_item.clone()])
.await;
let mut last_agent_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let mut turn_diff_tracker = TurnDiffTracker::new();
// Track if this is the first iteration - if so, include the initial input
let mut first_iteration = true;
loop {
// Note that pending_input would be something like a message the user
// submitted through the UI while the model was running. Though the UI
// may support this, the model might not.
let pending_input = sess
let mut pending_input = sess
.get_pending_input()
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<ResponseItem>>();
sess.record_conversation_items(&pending_input).await;
// On the first iteration, add the initial input to pending_input
// This ensures ephemeral images are included in the current turn
if first_iteration {
pending_input.insert(0, initial_response_item.clone());
first_iteration = false;
} else {
// Only record pending input to history on subsequent iterations
sess.record_conversation_items(&pending_input).await;
}
// Construct the input that we will send to the model. When using the
// Chat completions API (or ZDR clients), the model needs the full
@@ -1226,6 +1344,19 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
.await;
}
// Add any pending browser screenshots to the responses
let screenshot_responses = consume_pending_screenshots(&sess);
for screenshot_response in screenshot_responses {
responses.push(screenshot_response);
}
// If there are responses, add them to pending input for the next iteration
if !responses.is_empty() {
for response in &responses {
sess.add_pending_input(response.clone());
}
}
if responses.is_empty() {
debug!("Turn completed");
last_agent_message = get_last_assistant_message_from_turn(
@@ -1715,6 +1846,13 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
"browser_open" => handle_browser_open(sess, arguments, sub_id, call_id).await,
"browser_close" => handle_browser_close(sess, sub_id, call_id).await,
"browser_status" => handle_browser_status(sess, sub_id, call_id).await,
"browser_click" => handle_browser_click(sess, arguments, sub_id, call_id).await,
"browser_type" => handle_browser_type(sess, arguments, sub_id, call_id).await,
"browser_key" => handle_browser_key(sess, arguments, sub_id, call_id).await,
"browser_javascript" => handle_browser_javascript(sess, arguments, sub_id, call_id).await,
_ => {
match sess.mcp_connection_manager.parse_tool_name(&name) {
Some((server, tool_name)) => {
@@ -2250,3 +2388,498 @@ async fn drain_to_completed(sess: &Session, sub_id: &str, prompt: &Prompt) -> Co
}
}
}
/// Capture a screenshot from the browser and store it for the next model request
async fn capture_browser_screenshot(sess: &Session) -> Option<PathBuf> {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
if browser_manager.is_enabled().await {
match browser_manager.capture_screenshot().await {
Ok(screenshots) => {
if let Some(first_screenshot) = screenshots.first() {
tracing::info!("Captured browser screenshot: {}", first_screenshot.display());
return Some(first_screenshot.clone());
}
}
Err(e) => {
tracing::warn!("Failed to capture browser screenshot: {}", e);
}
}
}
}
None
}
/// Add a screenshot to pending screenshots for the next model request
fn add_pending_screenshot(sess: &Session, screenshot_path: PathBuf) {
let mut pending = sess.pending_browser_screenshots.lock().unwrap();
pending.push(screenshot_path);
tracing::info!("Added pending screenshot for next model request");
}
/// Consume pending screenshots and return them as ResponseInputItems
fn consume_pending_screenshots(sess: &Session) -> Vec<ResponseInputItem> {
let mut pending = sess.pending_browser_screenshots.lock().unwrap();
let screenshots = pending.drain(..).collect::<Vec<_>>();
screenshots.into_iter().map(|path| {
let metadata = format!("[EPHEMERAL:browser_screenshot] Browser screenshot at {}",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"));
// Read the screenshot file and create an ephemeral image
match std::fs::read(&path) {
Ok(bytes) => {
let mime = mime_guess::from_path(&path)
.first()
.map(|m| m.to_string())
.unwrap_or_else(|| "image/png".to_string());
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![
ContentItem::InputText { text: metadata },
ContentItem::InputImage {
image_url: format!("data:{mime};base64,{encoded}"),
},
],
}
}
Err(e) => {
tracing::error!("Failed to read screenshot {}: {}", path.display(), e);
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("Failed to load browser screenshot: {}", e),
}],
}
}
}
}).collect()
}
async fn handle_browser_open(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
// Parse the URL from arguments
let args: Result<Value, _> = serde_json::from_str(&arguments);
match args {
Ok(json) => {
let url = json.get("url")
.and_then(|v| v.as_str())
.unwrap_or("about:blank");
// Use the global browser manager
let needs_init = sess.browser_manager.lock().unwrap().is_none();
let browser_manager = if needs_init {
let manager = codex_browser::global::get_or_create_browser_manager().await;
// Enable the browser
manager.set_enabled_sync(true);
*sess.browser_manager.lock().unwrap() = Some(manager.clone());
Some(manager)
} else {
sess.browser_manager.lock().unwrap().clone()
};
if let Some(browser_manager) = browser_manager {
// Navigate to the URL
match browser_manager.goto(url).await {
Ok(_) => {
// Capture screenshot after navigation
if let Some(screenshot_path) = capture_browser_screenshot(sess).await {
add_pending_screenshot(sess, screenshot_path);
}
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Browser opened to: {}. Screenshots will be automatically attached to messages.", url),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to navigate browser to {}: {}", url, e),
success: Some(false),
},
}
}
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Failed to initialize browser manager.".to_string(),
success: Some(false),
},
}
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to parse browser_open arguments: {}", e),
success: Some(false),
},
},
}
}
async fn handle_browser_close(
sess: &Session,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
match browser_manager.stop().await {
Ok(_) => {
// Clear the browser manager from the session and global
*sess.browser_manager.lock().unwrap() = None;
codex_browser::global::clear_browser_manager().await;
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser closed. Screenshot capture disabled.".to_string(),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to close browser: {}", e),
success: Some(false),
},
}
}
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not currently open.".to_string(),
success: Some(false),
},
}
}
}
async fn handle_browser_status(
sess: &Session,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
let status = browser_manager.get_status().await;
let status_msg = if status.enabled {
if let Some(url) = status.current_url {
format!("Browser status: Enabled, currently at {}", url)
} else {
"Browser status: Enabled, no page loaded".to_string()
}
} else {
"Browser status: Disabled".to_string()
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: status_msg,
success: Some(true),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
success: Some(false),
},
}
}
}
async fn handle_browser_click(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
let args: Result<Value, _> = serde_json::from_str(&arguments);
match args {
Ok(json) => {
let x = json.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = json.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
match browser_manager.click(x, y).await {
Ok(_) => {
// Capture screenshot after clicking
if let Some(screenshot_path) = capture_browser_screenshot(sess).await {
add_pending_screenshot(sess, screenshot_path);
}
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Clicked at ({}, {})", x, y),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to click: {}", e),
success: Some(false),
},
}
}
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to parse browser_click arguments: {}", e),
success: Some(false),
},
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
success: Some(false),
},
}
}
}
async fn handle_browser_type(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
let args: Result<Value, _> = serde_json::from_str(&arguments);
match args {
Ok(json) => {
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
match browser_manager.type_text(text).await {
Ok(_) => {
// Capture screenshot after typing
if let Some(screenshot_path) = capture_browser_screenshot(sess).await {
add_pending_screenshot(sess, screenshot_path);
}
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Typed: {}", text),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to type text: {}", e),
success: Some(false),
},
}
}
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to parse browser_type arguments: {}", e),
success: Some(false),
},
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
success: Some(false),
},
}
}
}
async fn handle_browser_key(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
let args: Result<Value, _> = serde_json::from_str(&arguments);
match args {
Ok(json) => {
let key = json.get("key").and_then(|v| v.as_str()).unwrap_or("");
match browser_manager.press_key(key).await {
Ok(_) => {
// Capture screenshot after pressing key
if let Some(screenshot_path) = capture_browser_screenshot(sess).await {
add_pending_screenshot(sess, screenshot_path);
}
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Pressed key: {}", key),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to press key: {}", e),
success: Some(false),
},
}
}
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to parse browser_key arguments: {}", e),
success: Some(false),
},
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
success: Some(false),
},
}
}
}
async fn handle_browser_javascript(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let browser_manager = sess.browser_manager.lock().unwrap().clone();
if let Some(browser_manager) = browser_manager {
let args: Result<Value, _> = serde_json::from_str(&arguments);
match args {
Ok(json) => {
let code = json.get("code").and_then(|v| v.as_str()).unwrap_or("");
match browser_manager.execute_javascript(code).await {
Ok(result) => {
// Log the JavaScript execution result
tracing::info!("JavaScript execution returned: {:?}", result);
// Format the result for the LLM
let formatted_result = if let Some(obj) = result.as_object() {
// Check if it's our wrapped result format
if let (Some(success), Some(value)) = (obj.get("success"), obj.get("value")) {
let logs = obj.get("logs").and_then(|v| v.as_array());
let mut output = String::new();
if let Some(logs) = logs {
if !logs.is_empty() {
output.push_str("Console logs:\n");
for log in logs {
if let Some(log_str) = log.as_str() {
output.push_str(&format!(" {}\n", log_str));
}
}
output.push_str("\n");
}
}
if success.as_bool().unwrap_or(false) {
output.push_str("Result: ");
output.push_str(&serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string()));
} else if let Some(error) = obj.get("error") {
output.push_str("Error: ");
output.push_str(&error.to_string());
}
output
} else {
// Fallback to raw JSON if not in expected format
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
}
} else {
// Not an object, return as-is
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
};
tracing::info!("Returning to LLM: {}", formatted_result);
// Capture screenshot after executing JavaScript
if let Some(screenshot_path) = capture_browser_screenshot(sess).await {
add_pending_screenshot(sess, screenshot_path);
}
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: formatted_result,
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to execute JavaScript: {}", e),
success: Some(false),
},
}
}
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to parse browser_javascript arguments: {}", e),
success: Some(false),
},
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
success: Some(false),
},
}
}
}
+4
View File
@@ -1,4 +1,5 @@
use crate::config_profile::ConfigProfile;
use crate::config_types::BrowserConfig;
use crate::config_types::History;
use crate::config_types::McpServerConfig;
use crate::config_types::ReasoningEffort;
@@ -377,6 +378,9 @@ pub struct ConfigToml {
/// Collection of settings that are specific to the TUI.
pub tui: Option<Tui>,
/// Browser configuration for integrated screenshot capabilities.
pub browser: Option<BrowserConfig>,
/// When set to `true`, `AgentReasoning` events will be hidden from the
/// UI/output. Defaults to `false`.
pub hide_agent_reasoning: Option<bool>,
+51
View File
@@ -78,6 +78,57 @@ pub enum HistoryPersistence {
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Tui {}
/// Browser configuration for integrated screenshot capabilities.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub viewport: Option<BrowserViewportConfig>,
#[serde(default)]
pub wait: Option<BrowserWaitStrategy>,
#[serde(default)]
pub fullpage: bool,
#[serde(default)]
pub segments_max: Option<usize>,
#[serde(default)]
pub idle_timeout_ms: Option<u64>,
#[serde(default)]
pub format: Option<BrowserImageFormat>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct BrowserViewportConfig {
pub width: u32,
pub height: u32,
#[serde(default)]
pub device_scale_factor: Option<f64>,
#[serde(default)]
pub mobile: bool,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum BrowserWaitStrategy {
Event(String),
Delay { delay_ms: u64 },
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BrowserImageFormat {
Png,
Webp,
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxMode {
+53 -14
View File
@@ -146,23 +146,27 @@ pub enum ReasoningItemContent {
impl From<Vec<InputItem>> for ResponseInputItem {
fn from(items: Vec<InputItem>) -> Self {
Self::Message {
role: "user".to_string(),
content: items
.into_iter()
.filter_map(|c| match c {
InputItem::Text { text } => Some(ContentItem::InputText { text }),
InputItem::Image { image_url } => Some(ContentItem::InputImage { image_url }),
InputItem::LocalImage { path } => match std::fs::read(&path) {
let mut content_items = Vec::new();
for item in items {
match item {
InputItem::Text { text } => {
content_items.push(ContentItem::InputText { text });
}
InputItem::Image { image_url } => {
content_items.push(ContentItem::InputImage { image_url });
}
InputItem::LocalImage { path } => {
match std::fs::read(&path) {
Ok(bytes) => {
let mime = mime_guess::from_path(&path)
.first()
.map(|m| m.essence_str().to_owned())
.unwrap_or_else(|| "application/octet-stream".to_string());
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
Some(ContentItem::InputImage {
content_items.push(ContentItem::InputImage {
image_url: format!("data:{mime};base64,{encoded}"),
})
});
}
Err(err) => {
tracing::warn!(
@@ -170,11 +174,46 @@ impl From<Vec<InputItem>> for ResponseInputItem {
path.display(),
err
);
None
}
},
})
.collect::<Vec<ContentItem>>(),
}
}
InputItem::EphemeralImage { path, metadata } => {
tracing::info!("Processing ephemeral image: {} with metadata: {:?}", path.display(), metadata);
// Add metadata text BEFORE the image so the LLM sees context first
if let Some(meta) = metadata {
content_items.push(ContentItem::InputText {
text: format!("[EPHEMERAL:{}]", meta)
});
}
match std::fs::read(&path) {
Ok(bytes) => {
let mime = mime_guess::from_path(&path)
.first()
.map(|m| m.essence_str().to_owned())
.unwrap_or_else(|| "application/octet-stream".to_string());
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
tracing::info!("Created ephemeral image data URL with mime: {}", mime);
content_items.push(ContentItem::InputImage {
image_url: format!("data:{mime};base64,{encoded}"),
});
}
Err(err) => {
tracing::error!(
"Failed to read ephemeral image {} – {}",
path.display(),
err
);
}
}
}
}
}
Self::Message {
role: "user".to_string(),
content: content_items,
}
}
}
+150
View File
@@ -331,6 +331,15 @@ pub(crate) fn get_openai_tools(
if config.plan_tool {
tools.push(PLAN_TOOL.clone());
}
// Add browser tools
tools.push(create_browser_open_tool());
tools.push(create_browser_close_tool());
tools.push(create_browser_status_tool());
tools.push(create_browser_click_tool());
tools.push(create_browser_type_tool());
tools.push(create_browser_key_tool());
tools.push(create_browser_javascript_tool());
if let Some(mcp_tools) = mcp_tools {
for (name, tool) in mcp_tools {
@@ -498,3 +507,144 @@ mod tests {
);
}
}
fn create_browser_open_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"url".to_string(),
JsonSchema::String {
description: Some("The URL to navigate to (e.g., https://example.com)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "browser_open".to_string(),
description: "Opens a browser window and navigates to the specified URL. Screenshots will be automatically attached to subsequent messages.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
},
})
}
fn create_browser_close_tool() -> OpenAiTool {
let properties = BTreeMap::new();
OpenAiTool::Function(ResponsesApiTool {
name: "browser_close".to_string(),
description: "Closes the browser window and disables screenshot capture.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
fn create_browser_status_tool() -> OpenAiTool {
let properties = BTreeMap::new();
OpenAiTool::Function(ResponsesApiTool {
name: "browser_status".to_string(),
description: "Gets the current browser status including whether it's enabled, current URL, and viewport settings.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
fn create_browser_click_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"x".to_string(),
JsonSchema::Number {
description: Some("The X coordinate to click at".to_string()),
},
);
properties.insert(
"y".to_string(),
JsonSchema::Number {
description: Some("The Y coordinate to click at".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "browser_click".to_string(),
description: "Clicks at the specified coordinates in the browser window.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["x".to_string(), "y".to_string()]),
additional_properties: Some(false),
},
})
}
fn create_browser_type_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"text".to_string(),
JsonSchema::String {
description: Some("The text to type into the currently focused element".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "browser_type".to_string(),
description: "Types text into the currently focused element in the browser.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["text".to_string()]),
additional_properties: Some(false),
},
})
}
fn create_browser_key_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"key".to_string(),
JsonSchema::String {
description: Some("The key to press (e.g., 'Enter', 'Tab', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete')".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "browser_key".to_string(),
description: "Presses a keyboard key in the browser (e.g., Enter, Tab, Escape, arrow keys).".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["key".to_string()]),
additional_properties: Some(false),
},
})
}
fn create_browser_javascript_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"code".to_string(),
JsonSchema::String {
description: Some("The JavaScript code to execute in the browser context".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "browser_javascript".to_string(),
description: "Executes JavaScript code in the browser and returns the result. The code is wrapped to automatically capture return values and console.log output.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["code".to_string()]),
additional_properties: Some(false),
},
})
}
+8
View File
@@ -338,6 +338,14 @@ pub enum InputItem {
LocalImage {
path: std::path::PathBuf,
},
/// Ephemeral image (like browser screenshots) that should not be persisted in history.
/// This will be converted to an `Image` variant but marked as ephemeral.
EphemeralImage {
path: std::path::PathBuf,
/// Optional metadata to help identify the image (e.g., "screenshot:1234567890:https://example.com")
metadata: Option<String>,
},
}
/// Event Queue Entry - events from agent
+1
View File
@@ -25,6 +25,7 @@ chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
codex-ansi-escape = { path = "../ansi-escape" }
codex-arg0 = { path = "../arg0" }
codex-browser = { path = "../browser" }
codex-common = { path = "../common", features = [
"cli",
"elapsed",
+6
View File
@@ -11,6 +11,7 @@ use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
#[cfg(debug_assertions)]
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
@@ -381,6 +382,11 @@ impl App<'_> {
widget.add_prompts_output();
}
}
SlashCommand::Browser => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_browser_command(command_text);
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use std::collections::HashMap;
+267 -2
View File
@@ -34,6 +34,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tokio::sync::mpsc::UnboundedSender;
@@ -50,7 +51,9 @@ use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::live_wrap::RowBuilder;
use crate::text_block::TextBlock;
use crate::user_approval_widget::ApprovalRequest;
use codex_browser::{BrowserManager, config::BrowserConfig};
use codex_file_search::FileMatch;
use ratatui::style::Stylize;
@@ -83,6 +86,8 @@ pub(crate) struct ChatWidget<'a> {
// Store pending image paths keyed by their placeholder text
pending_images: HashMap<String, PathBuf>,
welcome_shown: bool,
// Browser manager for screenshot functionality
browser_manager: Arc<BrowserManager>,
}
struct UserMessage {
@@ -274,6 +279,11 @@ impl ChatWidget<'_> {
}
});
// Browser manager will be initialized lazily when needed
// For now, create a placeholder that will be replaced on first use
let browser_config = BrowserConfig::default();
let browser_manager = Arc::new(BrowserManager::new(browser_config));
Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
@@ -300,6 +310,7 @@ impl ChatWidget<'_> {
live_max_rows: 3,
pending_images: HashMap::new(),
welcome_shown: false,
browser_manager,
}
}
@@ -416,11 +427,110 @@ impl ChatWidget<'_> {
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
let mut items: Vec<InputItem> = Vec::new();
let mut browser_screenshot_count = 0;
// Check if browser mode is enabled and capture screenshot
// Always check both local and global browser managers
let local_browser_manager = self.browser_manager.clone();
// Check if either local or global browser is enabled
let local_enabled = local_browser_manager.config.try_read()
.map(|c| c.enabled)
.unwrap_or(false);
// Check global browser manager async
let (tx_global, rx_global) = std::sync::mpsc::channel();
tokio::spawn(async move {
let global_manager = codex_browser::global::get_browser_manager().await;
let enabled = if let Some(ref mgr) = global_manager {
mgr.is_enabled().await
} else {
false
};
let _ = tx_global.send((global_manager, enabled));
});
// Get global browser state
let (global_manager, global_enabled) = rx_global.recv_timeout(std::time::Duration::from_millis(100))
.unwrap_or((None, false));
// Use global manager if it exists and is enabled, otherwise use local
let (browser_manager, browser_enabled) = if global_enabled && global_manager.is_some() {
(global_manager.unwrap(), true)
} else if local_enabled {
(local_browser_manager, true)
} else if let Some(global) = global_manager {
// Global exists but not enabled, still prefer it over local
(global, false)
} else {
(local_browser_manager, false)
};
// Get current browser URL for display
let mut browser_url = None;
if browser_enabled {
tracing::info!("Browser is enabled, attempting to capture screenshot...");
// We need to capture screenshots synchronously before sending the message
// Use a channel to get the result from the async task
let (tx, rx) = std::sync::mpsc::channel();
let browser_manager_capture = browser_manager.clone();
tokio::spawn(async move {
tracing::info!("Starting async screenshot capture...");
let result = browser_manager_capture.capture_screenshot_with_url().await;
match &result {
Ok((paths, _)) => tracing::info!("Screenshot capture succeeded with {} images", paths.len()),
Err(e) => tracing::error!("Screenshot capture failed: {}", e),
}
let _ = tx.send(result);
});
// Wait for screenshot capture (with timeout)
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(Ok((screenshot_paths, url))) => {
tracing::info!("Captured {} browser screenshot(s)", screenshot_paths.len());
browser_screenshot_count = screenshot_paths.len();
let screenshot_url = url.clone();
browser_url = url;
// Add browser screenshots directly to items (ephemeral - not stored in image_paths for history)
for path in screenshot_paths {
tracing::info!("Browser screenshot attached: {}", path.display());
// Check if the file actually exists
if path.exists() {
tracing::info!("Screenshot file exists at: {}", path.display());
// Add as EphemeralImage with metadata for identification
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let metadata = format!("screenshot:{}:{}", timestamp, screenshot_url.as_deref().unwrap_or("unknown"));
items.push(InputItem::EphemeralImage {
path,
metadata: Some(metadata),
});
} else {
tracing::error!("Screenshot file does not exist at: {}", path.display());
}
}
}
Ok(Err(e)) => {
tracing::error!("Failed to capture browser screenshot: {}", e);
}
Err(_) => {
tracing::error!("Browser screenshot capture timed out");
}
}
} else {
tracing::info!("Browser is not enabled, skipping screenshot capture");
}
if !text.is_empty() {
items.push(InputItem::Text { text: text.clone() });
}
// Add user-provided images (these are persistent in history)
for path in image_paths {
items.push(InputItem::LocalImage { path });
}
@@ -429,6 +539,15 @@ impl ChatWidget<'_> {
return;
}
// Debug logging for what we're sending
let ephemeral_count = items.iter().filter(|item| {
matches!(item, InputItem::EphemeralImage { .. })
}).count();
let total_items = items.len();
if ephemeral_count > 0 {
tracing::info!("Sending {} items to model (including {} ephemeral images)", total_items, ephemeral_count);
}
self.codex_op_tx
.send(Op::UserInput { items })
.unwrap_or_else(|e| {
@@ -444,9 +563,25 @@ impl ChatWidget<'_> {
});
}
// Only show text portion in conversation history for now.
// Show text in conversation history, with a note about browser screenshots
if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
let display_text = if browser_screenshot_count > 0 {
if let Some(url) = browser_url {
format!("{}\n\n[Browser: {}]", text, url)
} else {
format!("{}\n\n[Browser screenshot attached]", text)
}
} else {
text.clone()
};
self.add_to_history(HistoryCell::new_user_prompt(display_text));
} else if browser_screenshot_count > 0 {
let prompt_text = if let Some(url) = browser_url {
format!("[Browser: {}]", url)
} else {
"[Browser screenshot attached]".to_string()
};
self.add_to_history(HistoryCell::new_user_prompt(prompt_text));
}
}
@@ -831,6 +966,136 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn handle_browser_command(&mut self, command_text: String) {
// Parse the browser subcommand
let parts: Vec<&str> = command_text.trim().split_whitespace().collect();
let browser_manager = self.browser_manager.clone();
let response = if parts.len() > 1 {
let first_arg = parts[1];
// Check if the first argument looks like a URL (has a dot or protocol)
let is_url = first_arg.contains("://") || first_arg.contains(".");
if is_url {
// It's a URL - enable browser mode and navigate to it
let url = parts[1..].join(" ");
// Ensure URL has protocol
let full_url = if !url.contains("://") {
format!("https://{}", url)
} else {
url.clone()
};
// Enable browser mode
browser_manager.set_enabled_sync(true);
// Navigate to URL and wait for it to load
let browser_manager_open = browser_manager.clone();
let url_for_goto = full_url.clone();
tokio::spawn(async move {
match browser_manager_open.goto(&url_for_goto).await {
Ok(result) => {
tracing::info!("Browser opened to: {} (title: {:?})", result.url, result.title);
// Add a small delay to ensure page is fully rendered
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
tracing::info!("Page should be loaded now");
}
Err(e) => {
tracing::error!("Failed to open browser: {}", e);
}
}
});
format!("Browser mode enabled. Opening: {}\nScreenshots will be attached to model requests.", full_url)
} else {
// It's a subcommand
match first_arg {
"off" => {
// Disable browser mode
browser_manager.set_enabled_sync(false);
// Close any open browser
let browser_manager_close = browser_manager.clone();
tokio::spawn(async move {
if let Err(e) = browser_manager_close.close().await {
tracing::error!("Failed to close browser: {}", e);
}
});
"Browser mode disabled.".to_string()
}
"status" => {
// Get status from BrowserManager
browser_manager.get_status_sync()
}
"fullpage" => {
if parts.len() > 2 {
match parts[2] {
"on" => {
// Enable full-page mode
browser_manager.set_fullpage_sync(true);
"Full-page screenshot mode enabled (max 8 segments).".to_string()
}
"off" => {
// Disable full-page mode
browser_manager.set_fullpage_sync(false);
"Full-page screenshot mode disabled.".to_string()
}
_ => "Usage: /browser fullpage [on|off]".to_string()
}
} else {
"Usage: /browser fullpage [on|off]".to_string()
}
}
"config" => {
if parts.len() > 3 {
let key = parts[2];
let value = parts[3..].join(" ");
// Update browser config
match key {
"viewport" => {
// Parse viewport dimensions like "1920x1080"
if let Some((width_str, height_str)) = value.split_once('x') {
if let (Ok(width), Ok(height)) = (width_str.parse::<u32>(), height_str.parse::<u32>()) {
browser_manager.set_viewport_sync(width, height);
format!("Browser viewport updated: {}x{}", width, height)
} else {
"Invalid viewport format. Use: /browser config viewport 1920x1080".to_string()
}
} else {
"Invalid viewport format. Use: /browser config viewport 1920x1080".to_string()
}
}
"segments_max" => {
if let Ok(max) = value.parse::<usize>() {
browser_manager.set_segments_max_sync(max);
format!("Browser segments_max updated: {}", max)
} else {
"Invalid segments_max value. Use a number.".to_string()
}
}
_ => format!("Unknown config key: {}. Available: viewport, segments_max", key)
}
} else {
"Usage: /browser config <key> <value>\nAvailable keys: viewport, segments_max".to_string()
}
}
_ => {
format!("Unknown browser command: '{}'\nUsage: /browser <url> | off | status | fullpage | config", first_arg)
}
}
}
} else {
"Browser commands:\n• /browser <url> - Open URL and enable browser mode\n• /browser off - Disable browser mode\n• /browser status - Show current status\n• /browser fullpage [on|off] - Toggle full-page mode\n• /browser config <key> <value> - Update configuration".to_string()
};
// Add the response to the UI as a background event
let lines = response.lines().map(|line| Line::from(line.to_string())).collect();
self.add_to_history(HistoryCell::BackgroundEvent {
view: TextBlock::new(lines),
});
}
/// Programmatically submit a user text message as if typed in the
/// composer. The text will be added to conversation history and sent to
/// the agent.
+2
View File
@@ -17,6 +17,7 @@ pub enum SlashCommand {
Compact,
Diff,
Status,
Browser,
Reasoning,
Prompts,
Logout,
@@ -35,6 +36,7 @@ impl SlashCommand {
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Browser => "manage browser mode (on/off/status/config)",
SlashCommand::Reasoning => "change reasoning effort (low/medium/high/none)",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Logout => "log out of Codex",