Fix formatting and build issues

- Applied cargo fmt to fix code formatting
- Fixed trailing whitespace issues
- Updated colors to use theme system
- Fixed merge-related syntax errors
- Recreated build-fast.sh for monorepo structure
This commit is contained in:
James Peter
2025-08-14 19:05:19 +10:00
parent 5ae98625f4
commit dbef6c6c69
31 changed files with 1292 additions and 968 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
# Fast build script for local development - optimized for speed
set -euo pipefail
cd codex-rs
# No need to cd, we're already in the Rust project root
# Use dev-fast profile by default for quick iteration
# Can override with: PROFILE=release ./build-fast.sh
@@ -37,13 +37,13 @@ if [ $? -eq 0 ]; then
ln -sf "../${PROFILE}/coder" "./target/release/coder"
# Update the symlink in codex-cli/bin for npm wrapper
CODEX_CLI_BIN="../codex-cli/bin/coder-aarch64-apple-darwin"
CODEX_CLI_BIN="./codex-cli/bin/coder-aarch64-apple-darwin"
mkdir -p "$(dirname "$CODEX_CLI_BIN")"
if [ -e "$CODEX_CLI_BIN" ]; then
rm -f "$CODEX_CLI_BIN"
fi
# Create relative symlink from codex-cli/bin to the actual binary
ln -sf "../../codex-rs/target/${PROFILE}/coder" "$CODEX_CLI_BIN"
ln -sf "../../target/${PROFILE}/coder" "$CODEX_CLI_BIN"
echo "✅ Symlinks updated"
echo ""
+36 -40
View File
@@ -234,10 +234,7 @@ impl BrowserManager {
info!("Connecting to Chrome via WebSocket...");
let connect_start = tokio::time::Instant::now();
let (browser, mut handler) = Browser::connect(ws).await?;
info!(
"Connected to Chrome in {:?}",
connect_start.elapsed()
);
info!("Connected to Chrome in {:?}", connect_start.elapsed());
let task =
tokio::spawn(
@@ -260,12 +257,12 @@ impl BrowserManager {
}
} else {
return Err(BrowserError::CdpError(
"No Chrome instance found with debug port".to_string()
"No Chrome instance found with debug port".to_string(),
));
}
} else {
return Err(BrowserError::CdpError(
"No CDP port configured for Chrome connection".to_string()
"No CDP port configured for Chrome connection".to_string(),
));
}
}
@@ -400,8 +397,11 @@ impl BrowserManager {
.arg("--disable-background-timer-throttling")
// Redirect Chrome logging to a file to keep terminal clean
.arg("--enable-logging")
.arg("--log-level=1") // 0 = INFO, 1 = WARNING, 2 = ERROR, 3 = FATAL (1 to reduce verbosity)
.arg(format!("--log-file={}/coder-chrome.log", std::env::temp_dir().display()))
.arg("--log-level=1") // 0 = INFO, 1 = WARNING, 2 = ERROR, 3 = FATAL (1 to reduce verbosity)
.arg(format!(
"--log-file={}/coder-chrome.log",
std::env::temp_dir().display()
))
// Suppress console output
.arg("--silent-launch")
// Set a longer timeout for CDP requests (60 seconds instead of default 30)
@@ -491,11 +491,9 @@ impl BrowserManager {
let mut page_guard = self.page.lock().await;
if let Some(page) = page_guard.as_ref() {
// Verify the page is still responsive
let check_result = tokio::time::timeout(
Duration::from_secs(2),
page.get_current_url()
).await;
let check_result =
tokio::time::timeout(Duration::from_secs(2), page.get_current_url()).await;
match check_result {
Ok(Ok(_)) => {
// Page is responsive
@@ -769,15 +767,13 @@ impl BrowserManager {
async fn ensure_browser(&self) -> Result<()> {
let mut browser_guard = self.browser.lock().await;
// Check if we have a browser instance
if let Some(browser) = browser_guard.as_ref() {
// Try to verify it's still connected with a simple operation
let check_result = tokio::time::timeout(
Duration::from_secs(2),
browser.version()
).await;
let check_result =
tokio::time::timeout(Duration::from_secs(2), browser.version()).await;
match check_result {
Ok(Ok(_)) => {
// Browser is responsive
@@ -793,7 +789,7 @@ impl BrowserManager {
}
}
}
// Need to start or restart the browser
drop(browser_guard);
info!("Starting/restarting browser connection...");
@@ -1000,33 +996,33 @@ impl BrowserManager {
};
// Get current URL with timeout
let current_url = match tokio::time::timeout(
Duration::from_secs(3),
page.get_current_url()
).await {
Ok(Ok(url)) => url,
Ok(Err(_)) | Err(_) => {
warn!("Failed to get current URL, using default");
"about:blank".to_string()
}
};
let current_url =
match tokio::time::timeout(Duration::from_secs(3), page.get_current_url()).await {
Ok(Ok(url)) => url,
Ok(Err(_)) | Err(_) => {
warn!("Failed to get current URL, using default");
"about:blank".to_string()
}
};
// Capture screenshots with timeout
let screenshot_result = tokio::time::timeout(
Duration::from_secs(15), // Allow up to 15 seconds for screenshot
page.screenshot(mode)
).await;
page.screenshot(mode),
)
.await;
let screenshots = match screenshot_result {
Ok(Ok(shots)) => shots,
Ok(Err(e)) => {
return Err(BrowserError::ScreenshotError(
format!("Screenshot capture failed: {}", e)
));
return Err(BrowserError::ScreenshotError(format!(
"Screenshot capture failed: {}",
e
)));
}
Err(_) => {
return Err(BrowserError::ScreenshotError(
"Screenshot capture timed out after 15 seconds".to_string()
"Screenshot capture timed out after 15 seconds".to_string(),
));
}
};
@@ -1060,19 +1056,19 @@ impl BrowserManager {
let page = self.get_or_create_page().await?;
page.move_mouse(x, y).await
}
/// Move the mouse by relative offset from current position
pub async fn move_mouse_relative(&self, dx: f64, dy: f64) -> Result<(f64, f64)> {
let page = self.get_or_create_page().await?;
page.move_mouse_relative(dx, dy).await
}
/// 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
}
/// Click at the current mouse position
pub async fn click_at_current(&self) -> Result<(f64, f64)> {
let page = self.get_or_create_page().await?;
+121 -95
View File
@@ -11,10 +11,10 @@ use chromiumoxide::cdp::browser_protocol::input::DispatchMouseEventType;
// Import MouseButton (New)
use chromiumoxide::cdp::browser_protocol::input::MouseButton;
// Import AddScriptToEvaluateOnNewDocumentParams (New)
use base64::Engine as _;
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams;
use base64::Engine as _;
use chromiumoxide::page::Page as CdpPage;
use std::sync::Arc;
use std::time::Duration;
@@ -62,7 +62,7 @@ impl Page {
let cdp_page_clone = page.cdp_page.clone();
tokio::spawn(async move {
if let Err(e) = Self::inject_tab_interception_script(&cdp_page_clone).await {
warn!("Failed to inject navigation interception script: {}", e);
warn!("Failed to inject navigation interception script: {}", e);
}
});
@@ -175,56 +175,63 @@ impl Page {
) -> Result<chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotReturns> {
// First try with from_surface(false) to avoid flashing, with timeout
let params = params_builder.clone().from_surface(false).build();
let first_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
self.cdp_page.execute(params)
).await;
self.cdp_page.execute(params),
)
.await;
match first_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e)) => {
// Log the initial failure
debug!("Screenshot with from_surface(false) failed: {}. Retrying with from_surface(true)...", e);
debug!(
"Screenshot with from_surface(false) failed: {}. Retrying with from_surface(true)...",
e
);
// Retry with from_surface(true) - this may cause flashing but will work when window is not visible
let retry_params = params_builder.from_surface(true).build();
let retry_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
self.cdp_page.execute(retry_params)
).await;
self.cdp_page.execute(retry_params),
)
.await;
match retry_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(retry_err)) => {
debug!("Screenshot retry with from_surface(true) also failed: {}", retry_err);
debug!(
"Screenshot retry with from_surface(true) also failed: {}",
retry_err
);
Err(retry_err.into())
}
Err(_) => {
Err(BrowserError::ScreenshotError(
"Screenshot retry timed out after 8 seconds".to_string()
))
}
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot retry timed out after 8 seconds".to_string(),
)),
}
}
Err(_) => {
// First attempt timed out, try with from_surface(true)
debug!("Screenshot with from_surface(false) timed out. Retrying with from_surface(true)...");
debug!(
"Screenshot with from_surface(false) timed out. Retrying with from_surface(true)..."
);
let retry_params = params_builder.from_surface(true).build();
let retry_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
self.cdp_page.execute(retry_params)
).await;
self.cdp_page.execute(retry_params),
)
.await;
match retry_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e)) => Err(e.into()),
Err(_) => {
Err(BrowserError::ScreenshotError(
"Screenshot timed out after 8 seconds on both attempts".to_string()
))
}
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out after 8 seconds on both attempts".to_string(),
)),
}
}
}
@@ -237,23 +244,24 @@ impl Page {
/// (NEW) Injects a virtual cursor element into the page at the current coordinates.
pub async fn inject_virtual_cursor(&self) -> Result<()> {
let cursor = self.cursor_state.lock().await.clone();
let cursor_x = cursor.x;
let cursor_y = cursor.y;
let cursor = self.cursor_state.lock().await.clone();
let cursor_x = cursor.x;
let cursor_y = cursor.y;
// Creates (once) and updates a two-part virtual cursor:
// - Arrow (instant follow)
// - Badge (slight lag / offset for a nicer feel)
//
// Tunables inside script:
// ARROW_SIZE_PX => overall design width (scales arrow)
// BADGE_SIZE_PX => overall design width (scales badge)
// TIP_X/TIP_Y => pixel nudge so the arrow tip lands exactly on (x,y)
// BADGE_OFF_* => relative offset of the trailing badge
// LAG => how much the badge trails; lower = looser, higher = tighter
//
// To remove later: evaluate `window.__vc?.destroy()`
let script = format!(r#"
// Creates (once) and updates a two-part virtual cursor:
// - Arrow (instant follow)
// - Badge (slight lag / offset for a nicer feel)
//
// Tunables inside script:
// ARROW_SIZE_PX => overall design width (scales arrow)
// BADGE_SIZE_PX => overall design width (scales badge)
// TIP_X/TIP_Y => pixel nudge so the arrow tip lands exactly on (x,y)
// BADGE_OFF_* => relative offset of the trailing badge
// LAG => how much the badge trails; lower = looser, higher = tighter
//
// To remove later: evaluate `window.__vc?.destroy()`
let script = format!(
r#"
(function(x, y) {{
const ns = 'http://www.w3.org/2000/svg';
const VIEW_W = 40, VIEW_H = 30; // original viewBox of your assets
@@ -511,11 +519,14 @@ impl Page {
window.__vc.moveTo(x, y);
}}
}})({cursor_x}, {cursor_y});
"#, cursor_x=cursor_x, cursor_y=cursor_y);
"#,
cursor_x = cursor_x,
cursor_y = cursor_y
);
self.cdp_page.evaluate(script).await?;
Ok(())
}
self.cdp_page.evaluate(script).await?;
Ok(())
}
/// (NEW) Ensures an editable element is focused before typing. (Ported from TS implementation)
async fn ensure_editable_focused(&self) -> Result<bool> {
@@ -524,7 +535,8 @@ impl Page {
let cursor_y = cursor.y;
// The JS logic from browser_session.ts
let script = format!(r#"
let script = format!(
r#"
(function(cursorX, cursorY) {{
const editable = el => el && (
// Refined list: exclude input types not meant for direct typing
@@ -576,12 +588,13 @@ impl Page {
}}
return false;
}})({cursor_x}, {cursor_y})
"#, cursor_x=cursor_x, cursor_y=cursor_y);
"#,
cursor_x = cursor_x,
cursor_y = cursor_y
);
let result = self.cdp_page.evaluate(script).await?;
let focused = result.value()
.and_then(|v| v.as_bool())
.unwrap_or(false);
let focused = result.value().and_then(|v| v.as_bool()).unwrap_or(false);
Ok(focused)
}
@@ -594,7 +607,7 @@ impl Page {
// Navigate to the URL with retry on timeout
let max_retries = 3;
let mut last_error = None;
for attempt in 1..=max_retries {
match self.cdp_page.goto(url).await {
Ok(_) => {
@@ -604,9 +617,12 @@ impl Page {
Err(e) => {
let error_str = e.to_string();
if error_str.contains("Request timed out") || error_str.contains("timeout") {
warn!("Navigation timeout on attempt {}/{}: {}", attempt, max_retries, error_str);
warn!(
"Navigation timeout on attempt {}/{}: {}",
attempt, max_retries, error_str
);
last_error = Some(e);
if attempt < max_retries {
// Wait before retry, increasing delay each time
let delay_ms = 1000 * attempt as u64;
@@ -621,12 +637,13 @@ impl Page {
}
}
}
// If we exhausted retries, return the last error
if let Some(e) = last_error {
return Err(BrowserError::CdpError(
format!("Navigation failed after {} retries: {}", max_retries, e)
));
return Err(BrowserError::CdpError(format!(
"Navigation failed after {} retries: {}",
max_retries, e
)));
}
// Wait according to the strategy
@@ -701,7 +718,7 @@ impl Page {
warn!("Failed to inject virtual cursor: {}", e);
// Continue with screenshot even if cursor injection fails
}
// Allow a brief moment for the cursor SVG to render
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
@@ -715,7 +732,6 @@ impl Page {
}
}
pub async fn screenshot_viewport(&self) -> Result<Vec<Screenshot>> {
// Safe viewport capture: do not change device metrics or viewport.
// Measure CSS viewport size via JS and capture a clipped image
@@ -735,18 +751,20 @@ impl Page {
.await
.unwrap_or(serde_json::Value::Null);
let doc_w = probe
.get("w")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let doc_h = probe
.get("h")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let doc_w = probe.get("w").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let doc_h = probe.get("h").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
// Fall back to configured viewport if probe failed
let vw = if doc_w > 0 { doc_w } else { self.config.viewport.width };
let vh = if doc_h > 0 { doc_h } else { self.config.viewport.height };
let vw = if doc_w > 0 {
doc_w
} else {
self.config.viewport.width
};
let vh = if doc_h > 0 {
doc_h
} else {
self.config.viewport.height
};
// Clamp to configured maximums to keep images small for the LLM
let target_w = vw.min(self.config.viewport.width);
@@ -816,7 +834,9 @@ impl Page {
let data_b64: &str = resp.data.as_ref();
let data = base64::engine::general_purpose::STANDARD
.decode(data_b64.as_bytes())
.map_err(|e| BrowserError::ScreenshotError(format!("base64 decode failed: {}", e)))?;
.map_err(|e| {
BrowserError::ScreenshotError(format!("base64 decode failed: {}", e))
})?;
shots.push(Screenshot {
data,
width: vw,
@@ -846,15 +866,15 @@ impl Page {
ImageFormat::Webp => CaptureScreenshotFormat::Webp,
};
let params_builder = CaptureScreenshotParams::builder()
.format(format)
.clip(chromiumoxide::cdp::browser_protocol::page::Viewport {
let params_builder = 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,
});
},
);
let resp = self.capture_screenshot_with_retry(params_builder).await?;
let data_b64: &str = resp.data.as_ref();
@@ -908,7 +928,9 @@ impl Page {
}
}
pub async fn update_viewport(&self, _viewport: ViewportConfig) -> Result<()> { Ok(()) }
pub async fn update_viewport(&self, _viewport: ViewportConfig) -> Result<()> {
Ok(())
}
// Move the mouse by relative offset from current position
pub async fn move_mouse_relative(&self, dx: f64, dy: f64) -> Result<(f64, f64)> {
@@ -917,19 +939,21 @@ impl Page {
let current_x = cursor.x;
let current_y = cursor.y;
drop(cursor);
// Calculate new position
let new_x = current_x + dx;
let new_y = current_y + dy;
debug!("Moving mouse relatively by ({}, {}) from ({}, {}) to ({}, {})",
dx, dy, current_x, current_y, new_x, new_y);
debug!(
"Moving mouse relatively by ({}, {}) from ({}, {}) to ({}, {})",
dx, dy, current_x, current_y, new_x, new_y
);
// Use absolute move with the calculated position
self.move_mouse(new_x, new_y).await?;
Ok((new_x, new_y))
}
// (NEW) Move the mouse to the specified coordinates
pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
debug!("Moving mouse to ({}, {})", x, y);
@@ -957,7 +981,8 @@ impl Page {
// First check if cursor exists, if not inject it
let check_script = "typeof window.__vc !== 'undefined'";
let cursor_exists = self.cdp_page
let cursor_exists = self
.cdp_page
.evaluate(check_script)
.await
.ok()
@@ -972,7 +997,8 @@ impl Page {
}
// Update cursor position
let _ = self.cdp_page
let _ = self
.cdp_page
.evaluate(format!(
"window.__vc && window.__vc.update({:.0}, {:.0});",
move_x, move_y
@@ -1044,7 +1070,7 @@ impl Page {
}
}
"#;
// Start the click animation
let _ = self.cdp_page.evaluate(animation_script).await;
@@ -1134,7 +1160,7 @@ impl Page {
}
}
"#;
// Start the click animation
let _ = self.cdp_page.evaluate(animation_script).await;
@@ -1174,12 +1200,12 @@ impl Page {
// Replace em dashes with regular dashes
let processed_text = text.replace('—', " - ");
debug!("Typing text: {}", processed_text);
// Ensure an editable element is focused first
self.ensure_editable_focused().await?;
let text_len = processed_text.len();
if text_len < 20 {
// Short text: character-by-character with natural delays
for ch in processed_text.chars() {
@@ -1189,7 +1215,7 @@ impl Page {
.build()
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
// Natural typing delay with slight randomization
let delay = 50 + (rand::random::<u64>() % 30);
tokio::time::sleep(Duration::from_millis(delay)).await;
@@ -1202,7 +1228,7 @@ impl Page {
.chunks(chunk_size)
.map(|chunk| chunk.iter().collect())
.collect();
for chunk in chunks {
// Type each character in the chunk
for ch in chunk.chars() {
@@ -1213,7 +1239,7 @@ impl Page {
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
}
// Delay between chunks
let delay = 100 + (rand::random::<u64>() % 50);
tokio::time::sleep(Duration::from_millis(delay)).await;
@@ -1226,7 +1252,7 @@ impl Page {
.chunks(chunk_size)
.map(|chunk| chunk.iter().collect())
.collect();
for (i, chunk) in chunks.iter().enumerate() {
// Type each character in the chunk
for ch in chunk.chars() {
@@ -1237,7 +1263,7 @@ impl Page {
.map_err(|e| BrowserError::CdpError(e))?;
self.cdp_page.execute(params).await?;
}
// Add occasional longer pauses to simulate thinking
if i % 5 == 0 && i > 0 {
let delay = 300 + (rand::random::<u64>() % 200);
@@ -1248,7 +1274,7 @@ impl Page {
}
}
}
Ok(())
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Fast build script for local development - optimized for speed
set -euo pipefail
# Use dev-fast profile by default for quick iteration
# Can override with: PROFILE=release ./build-fast.sh
PROFILE="${PROFILE:-dev-fast}"
# Determine the correct binary path based on profile
if [ "$PROFILE" = "dev-fast" ]; then
BIN_PATH="./target/dev-fast/coder"
elif [ "$PROFILE" = "dev" ]; then
BIN_PATH="./target/debug/coder"
else
BIN_PATH="./target/${PROFILE}/coder"
fi
echo "Building coder binary (${PROFILE} mode)..."
# Build for native target (no --target flag) for maximum speed
# This reuses the host stdlib and normal cache
cargo build --profile "${PROFILE}" --bin coder
# Check if build succeeded
if [ $? -eq 0 ]; then
echo "✅ Build successful!"
echo "Binary location: ${BIN_PATH}"
# Keep old symlink locations working for compatibility
# Create symlink in target/release for npm wrapper expectations
mkdir -p ./target/release
if [ -e "./target/release/coder" ]; then
rm -f ./target/release/coder
fi
ln -sf "../${PROFILE}/coder" "./target/release/coder"
# Update the symlink in codex-cli/bin for npm wrapper
CODEX_CLI_BIN="./codex-cli/bin/coder-aarch64-apple-darwin"
mkdir -p "$(dirname "$CODEX_CLI_BIN")"
if [ -e "$CODEX_CLI_BIN" ]; then
rm -f "$CODEX_CLI_BIN"
fi
# Create relative symlink from codex-cli/bin to the actual binary
ln -sf "../../target/${PROFILE}/coder" "$CODEX_CLI_BIN"
echo "✅ Symlinks updated"
echo ""
echo "You can now run: coder"
echo "Binary size: $(du -h ${BIN_PATH} | cut -f1)"
echo ""
echo "Build profile: ${PROFILE}"
if [ "$PROFILE" = "dev-fast" ]; then
echo " → Optimized for fast iteration (incremental builds enabled)"
echo " → For production build, use: PROFILE=release-prod ./build-fast.sh"
fi
else
echo "❌ Build failed"
exit 1
fi
+1
View File
@@ -0,0 +1 @@
../../target/dev-fast/coder
+45 -21
View File
@@ -52,7 +52,9 @@ pub(crate) async fn stream_chat_completions(
// If the message contains any images, we must use the
// multi-modal array form supported by Chat Completions:
// [{ type: "text", text: "..." }, { type: "image_url", image_url: { url: "data:..." } }]
let contains_image = content.iter().any(|c| matches!(c, ContentItem::InputImage { .. }));
let contains_image = content
.iter()
.any(|c| matches!(c, ContentItem::InputImage { .. }));
if contains_image {
let mut parts = Vec::<serde_json::Value>::new();
@@ -78,7 +80,8 @@ pub(crate) async fn stream_chat_completions(
let mut text = String::new();
for c in content {
match c {
ContentItem::InputText { text: t } | ContentItem::OutputText { text: t } => {
ContentItem::InputText { text: t }
| ContentItem::OutputText { text: t } => {
text.push_str(t);
}
_ => {}
@@ -155,7 +158,9 @@ pub(crate) async fn stream_chat_completions(
// Start logging the request and get a request_id to track the response
let request_id = if let Ok(logger) = debug_logger.lock() {
logger.start_request_log(&endpoint, &payload).unwrap_or_default()
logger
.start_request_log(&endpoint, &payload)
.unwrap_or_default()
} else {
String::new()
};
@@ -177,10 +182,14 @@ pub(crate) async fn stream_chat_completions(
Ok(resp) if resp.status().is_success() => {
// Log successful response initiation
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "stream_initiated", &serde_json::json!({
"status": "success",
"status_code": resp.status().as_u16()
}));
let _ = logger.append_response_event(
&request_id,
"stream_initiated",
&serde_json::json!({
"status": "success",
"status_code": resp.status().as_u16()
}),
);
}
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
@@ -201,10 +210,14 @@ pub(crate) async fn stream_chat_completions(
let body = (res.text().await).unwrap_or_default();
// Log error response
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "error", &serde_json::json!({
"status": status.as_u16(),
"body": body
}));
let _ = logger.append_response_event(
&request_id,
"error",
&serde_json::json!({
"status": status.as_u16(),
"body": body
}),
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body));
@@ -229,9 +242,13 @@ pub(crate) async fn stream_chat_completions(
if attempt > max_retries {
// Log network error
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "network_error", &serde_json::json!({
"error": e.to_string()
}));
let _ = logger.append_response_event(
&request_id,
"network_error",
&serde_json::json!({
"error": e.to_string()
}),
);
let _ = logger.end_request_log(&request_id);
}
return Err(e.into());
@@ -355,7 +372,7 @@ async fn process_chat_sse<S>(
Err(_) => continue,
};
trace!("chat_completions received SSE chunk: {chunk:?}");
// Log the SSE chunk to debug log
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "sse_event", &chunk);
@@ -373,7 +390,7 @@ async fn process_chat_sse<S>(
if let Some(item_id) = choice.get("item_id").and_then(|id| id.as_str()) {
current_item_id = Some(item_id.to_string());
}
// Handle assistant content tokens as streaming deltas.
if let Some(content) = choice
.get("delta")
@@ -525,7 +542,7 @@ async fn process_chat_sse<S>(
if let Ok(logger) = debug_logger.lock() {
let _ = logger.end_request_log(&request_id);
}
return; // End processing for this SSE stream.
}
}
@@ -593,7 +610,8 @@ where
// seen any deltas; otherwise, deltas already built the
// cumulative text and this would duplicate it.
if this.cumulative.is_empty() {
if let crate::models::ResponseItem::Message { content, id, .. } = &item {
if let crate::models::ResponseItem::Message { content, id, .. } = &item
{
// Capture the item_id if present
if let Some(item_id) = id {
this.cumulative_item_id = Some(item_id.clone());
@@ -610,7 +628,7 @@ where
// Swallow assistant message here; emit on Completed.
continue;
}
// Also capture item_id from Reasoning items
if let crate::models::ResponseItem::Reasoning { id, .. } = &item {
if !id.is_empty() {
@@ -691,7 +709,10 @@ where
}
if matches!(this.mode, AggregateMode::Streaming) {
// In streaming mode, also forward the delta immediately.
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { delta, item_id })));
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta {
delta,
item_id,
})));
} else {
continue;
}
@@ -705,7 +726,10 @@ where
}
if matches!(this.mode, AggregateMode::Streaming) {
// In streaming mode, also forward the delta immediately.
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { delta, item_id })));
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta {
delta,
item_id,
})));
} else {
continue;
}
+52 -33
View File
@@ -31,6 +31,7 @@ use crate::client_common::create_reasoning_param_for_request;
use crate::config::Config;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::debug_logger::DebugLogger;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::UsageLimitReachedError;
@@ -44,7 +45,6 @@ use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use std::sync::Arc;
use std::sync::Mutex;
use crate::debug_logger::DebugLogger;
#[derive(Debug, Deserialize)]
struct ErrorResponse {
@@ -192,15 +192,13 @@ impl ModelClient {
let max_retries = self.provider.request_max_retries();
let endpoint = self.provider.get_full_url(&auth);
trace!(
"POST to {}: {}",
endpoint,
serde_json::to_string(&payload)?
);
trace!("POST to {}: {}", endpoint, serde_json::to_string(&payload)?);
// Start logging the request and get a request_id to track the response
let request_id = if let Ok(logger) = self.debug_logger.lock() {
logger.start_request_log(&endpoint, &serde_json::to_value(&payload)?).unwrap_or_default()
logger
.start_request_log(&endpoint, &serde_json::to_value(&payload)?)
.unwrap_or_default()
} else {
String::new()
};
@@ -252,14 +250,18 @@ impl ModelClient {
Ok(resp) if resp.status().is_success() => {
// Log successful response initiation
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "stream_initiated", &serde_json::json!({
"status": "success",
"status_code": resp.status().as_u16(),
"x_request_id": resp.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
}));
let _ = logger.append_response_event(
&request_id,
"stream_initiated",
&serde_json::json!({
"status": "success",
"status_code": resp.status().as_u16(),
"x_request_id": resp.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
}),
);
}
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
@@ -299,10 +301,14 @@ impl ModelClient {
let body = res.text().await.unwrap_or_default();
// Log error response
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(&request_id, "error", &serde_json::json!({
"status": status.as_u16(),
"body": body
}));
let _ = logger.append_response_event(
&request_id,
"error",
&serde_json::json!({
"status": status.as_u16(),
"body": body
}),
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body));
@@ -424,6 +430,7 @@ async fn process_sse<S>(
// If the stream stays completely silent for an extended period treat it as disconnected.
// The response id returned from the "complete" message.
let mut response_completed: Option<ResponseCompleted> = None;
let mut response_error: Option<CodexErr> = None;
// Track the current item_id to include with delta events
let mut current_item_id: Option<String> = None;
@@ -523,9 +530,9 @@ async fn process_sse<S>(
} else {
// Check within the parsed item structure
match &item {
ResponseItem::Message { id, .. } |
ResponseItem::FunctionCall { id, .. } |
ResponseItem::LocalShellCall { id, .. } => {
ResponseItem::Message { id, .. }
| ResponseItem::FunctionCall { id, .. }
| ResponseItem::LocalShellCall { id, .. } => {
if let Some(item_id) = id {
current_item_id = Some(item_id.clone());
}
@@ -544,9 +551,9 @@ async fn process_sse<S>(
}
"response.output_text.delta" => {
if let Some(delta) = event.delta {
let event = ResponseEvent::OutputTextDelta {
delta,
item_id: current_item_id.clone()
let event = ResponseEvent::OutputTextDelta {
delta,
item_id: current_item_id.clone(),
};
if tx_event.send(Ok(event)).await.is_err() {
return;
@@ -555,9 +562,9 @@ async fn process_sse<S>(
}
"response.reasoning_summary_text.delta" => {
if let Some(delta) = event.delta {
let event = ResponseEvent::ReasoningSummaryDelta {
delta,
item_id: current_item_id.clone()
let event = ResponseEvent::ReasoningSummaryDelta {
delta,
item_id: current_item_id.clone(),
};
if tx_event.send(Ok(event)).await.is_err() {
return;
@@ -566,9 +573,9 @@ async fn process_sse<S>(
}
"response.reasoning_text.delta" => {
if let Some(delta) = event.delta {
let event = ResponseEvent::ReasoningContentDelta {
delta,
item_id: current_item_id.clone()
let event = ResponseEvent::ReasoningContentDelta {
delta,
item_id: current_item_id.clone(),
};
if tx_event.send(Ok(event)).await.is_err() {
return;
@@ -731,7 +738,13 @@ mod tests {
let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
tokio::spawn(process_sse(stream, tx, provider.stream_idle_timeout(), debug_logger, String::new()));
tokio::spawn(process_sse(
stream,
tx,
provider.stream_idle_timeout(),
debug_logger,
String::new(),
));
let mut events = Vec::new();
while let Some(ev) = rx.recv().await {
@@ -762,7 +775,13 @@ mod tests {
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
tokio::spawn(process_sse(stream, tx, provider.stream_idle_timeout(), debug_logger, String::new()));
tokio::spawn(process_sse(
stream,
tx,
provider.stream_idle_timeout(),
debug_logger,
String::new(),
));
let mut out = Vec::new();
while let Some(ev) = rx.recv().await {
+22 -7
View File
@@ -120,11 +120,14 @@ impl Prompt {
}
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
let mut input_with_instructions = Vec::with_capacity(self.input.len() + self.status_items.len() + 3);
let mut input_with_instructions =
Vec::with_capacity(self.input.len() + self.status_items.len() + 3);
input_with_instructions.push(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text: ADDITIONAL_INSTRUCTIONS.to_string() }],
content: vec![ContentItem::InputText {
text: ADDITIONAL_INSTRUCTIONS.to_string(),
}],
});
if let Some(ec) = self.get_formatted_environment_context() {
input_with_instructions.push(ResponseItem::Message {
@@ -147,7 +150,10 @@ impl Prompt {
ResponseItem::FunctionCallOutput { call_id, .. } => {
if !seen_call_ids.insert(call_id.clone()) {
// Skip duplicate function call output
tracing::debug!("Filtering duplicate FunctionCallOutput with call_id: {} from input", call_id);
tracing::debug!(
"Filtering duplicate FunctionCallOutput with call_id: {} from input",
call_id
);
continue;
}
}
@@ -155,7 +161,7 @@ impl Prompt {
}
input_with_instructions.push(item.clone());
}
// Add status items at the end so they're fresh for each request
input_with_instructions.extend(self.status_items.clone());
input_with_instructions
@@ -170,9 +176,18 @@ pub enum ResponseEvent {
response_id: String,
token_usage: Option<TokenUsage>,
},
OutputTextDelta { delta: String, item_id: Option<String> },
ReasoningSummaryDelta { delta: String, item_id: Option<String> },
ReasoningContentDelta { delta: String, item_id: Option<String> },
OutputTextDelta {
delta: String,
item_id: Option<String>,
},
ReasoningSummaryDelta {
delta: String,
item_id: Option<String>,
},
ReasoningContentDelta {
delta: String,
item_id: Option<String>,
},
ReasoningSummaryPartAdded,
}
+391 -334
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -163,7 +163,7 @@ pub struct Config {
/// The value for the `originator` header included with Responses API requests.
pub internal_originator: Option<String>,
/// Enable debug logging of LLM requests and responses
pub debug: bool,
}
+39 -11
View File
@@ -42,7 +42,12 @@ impl ConversationHistory {
// replace its text with the new content.
if let Some(id) = new_id.as_ref() {
if let Some(idx) = find_assistant_index_by_id(&self.items, id) {
if let ResponseItem::Message { id: existing_id, content, .. } = &mut self.items[idx] {
if let ResponseItem::Message {
id: existing_id,
content,
..
} = &mut self.items[idx]
{
// Ensure the existing entry carries the id
if existing_id.is_none() {
*existing_id = Some(id.clone());
@@ -55,11 +60,18 @@ impl ConversationHistory {
// 2) Otherwise, attempt to merge with the immediately previous
// assistant message (streamed deltas case).
if let Some(ResponseItem::Message { id: last_id, role: last_role, content: last_content }) = self.items.last_mut() {
if let Some(ResponseItem::Message {
id: last_id,
role: last_role,
content: last_content,
}) = self.items.last_mut()
{
if last_role == "assistant" {
// If new item has an ID, propagate it to the existing entry
if last_id.is_none() {
if let Some(id) = new_id.clone() { *last_id = Some(id); }
if let Some(id) = new_id.clone() {
*last_id = Some(id);
}
}
replace_or_append_text(last_content, new_content);
continue;
@@ -74,11 +86,17 @@ impl ConversationHistory {
let already_exists = self.items.iter().any(|existing| {
matches!(existing, ResponseItem::FunctionCallOutput { call_id: existing_id, .. } if existing_id == call_id)
});
if already_exists {
debug!("Skipping duplicate FunctionCallOutput for call_id: {} (already in history)", call_id);
debug!(
"Skipping duplicate FunctionCallOutput for call_id: {} (already in history)",
call_id
);
} else {
debug!("Recording FunctionCallOutput to history for call_id: {}", call_id);
debug!(
"Recording FunctionCallOutput to history for call_id: {}",
call_id
);
self.items.push(item.clone());
}
}
@@ -87,9 +105,12 @@ impl ConversationHistory {
let already_exists = self.items.iter().any(|existing| {
matches!(existing, ResponseItem::FunctionCall { call_id: existing_id, .. } if existing_id == call_id)
});
if already_exists {
debug!("Skipping duplicate FunctionCall for call_id: {} (already in history)", call_id);
debug!(
"Skipping duplicate FunctionCall for call_id: {} (already in history)",
call_id
);
} else {
self.items.push(item.clone());
}
@@ -101,7 +122,6 @@ impl ConversationHistory {
}
}
pub(crate) fn keep_last_messages(&mut self, n: usize) {
if n == 0 {
self.items.clear();
@@ -192,7 +212,10 @@ fn replace_text_content(content: &mut Vec<crate::models::ContentItem>, text: &st
}
/// Merge strategy: if the new text starts with the old, replace; otherwise append deltas.
fn replace_or_append_text(existing: &mut Vec<crate::models::ContentItem>, new_content: &Vec<crate::models::ContentItem>) {
fn replace_or_append_text(
existing: &mut Vec<crate::models::ContentItem>,
new_content: &Vec<crate::models::ContentItem>,
) {
let prev = collect_text_content(existing);
let new_full = collect_text_content(new_content);
if !prev.is_empty() && new_full.starts_with(&prev) {
@@ -205,7 +228,12 @@ fn replace_or_append_text(existing: &mut Vec<crate::models::ContentItem>, new_co
fn find_assistant_index_by_id(items: &Vec<ResponseItem>, id: &str) -> Option<usize> {
// Search from the end (most recent first) for better performance in practice.
for (idx, it) in items.iter().enumerate().rev() {
if let ResponseItem::Message { id: Some(existing), role, .. } = it {
if let ResponseItem::Message {
id: Some(existing),
role,
..
} = it
{
if role == "assistant" && existing == id {
return Some(idx);
}
+36 -39
View File
@@ -1,9 +1,9 @@
use std::fs;
use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::Mutex;
use chrono::Local;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use uuid::Uuid;
struct StreamInfo {
@@ -43,7 +43,11 @@ impl DebugLogger {
}
/// Start a new request/response log file and return the request ID
pub fn start_request_log(&self, endpoint: &str, payload: &Value) -> Result<String, std::io::Error> {
pub fn start_request_log(
&self,
endpoint: &str,
payload: &Value,
) -> Result<String, std::io::Error> {
if !self.enabled {
return Ok(String::new());
}
@@ -51,7 +55,7 @@ impl DebugLogger {
let timestamp = Local::now();
let request_id = Uuid::new_v4().to_string();
let request_id_short = &request_id[..8]; // Use first 8 chars of UUID for brevity
// Create request file with pretty-printed JSON
let request_filename = format!(
"{}_{}_request.json",
@@ -59,7 +63,7 @@ impl DebugLogger {
request_id_short
);
let request_file_path = self.log_dir.join(request_filename);
// Create request object with metadata
let request_entry = serde_json::json!({
"timestamp": timestamp.to_rfc3339(),
@@ -82,17 +86,25 @@ impl DebugLogger {
// Store the stream info for this request_id
if let Ok(mut streams) = self.active_streams.lock() {
streams.insert(request_id.clone(), StreamInfo {
response_file: response_file_path,
events: Vec::new(),
});
streams.insert(
request_id.clone(),
StreamInfo {
response_file: response_file_path,
events: Vec::new(),
},
);
}
Ok(request_id)
}
/// Append a response event to the in-memory event list
pub fn append_response_event(&self, request_id: &str, event_type: &str, data: &Value) -> Result<(), std::io::Error> {
pub fn append_response_event(
&self,
request_id: &str,
event_type: &str,
data: &Value,
) -> Result<(), std::io::Error> {
if !self.enabled || request_id.is_empty() {
return Ok(());
}
@@ -143,13 +155,10 @@ impl DebugLogger {
}
let timestamp = Local::now();
let filename = format!(
"{}_request.json",
timestamp.format("%Y%m%d_%H%M%S%.3f")
);
let filename = format!("{}_request.json", timestamp.format("%Y%m%d_%H%M%S%.3f"));
let file_path = self.log_dir.join(filename);
let log_entry = serde_json::json!({
"timestamp": timestamp.to_rfc3339(),
"type": "request",
@@ -169,16 +178,13 @@ impl DebugLogger {
}
let timestamp = Local::now();
let filename = format!(
"{}_response.json",
timestamp.format("%Y%m%d_%H%M%S%.3f")
);
let filename = format!("{}_response.json", timestamp.format("%Y%m%d_%H%M%S%.3f"));
let file_path = self.log_dir.join(filename);
let log_entry = serde_json::json!({
"timestamp": timestamp.to_rfc3339(),
"type": "response",
"type": "response",
"endpoint": endpoint,
"response": response
});
@@ -195,13 +201,10 @@ impl DebugLogger {
}
let timestamp = Local::now();
let filename = format!(
"{}_stream.txt",
timestamp.format("%Y%m%d_%H%M%S%.3f")
);
let filename = format!("{}_stream.txt", timestamp.format("%Y%m%d_%H%M%S%.3f"));
let file_path = self.log_dir.join(filename);
let log_entry = format!(
"=== Stream Chunk at {} ===\nEndpoint: {}\n\n{}\n",
timestamp.to_rfc3339(),
@@ -220,13 +223,10 @@ impl DebugLogger {
}
let timestamp = Local::now();
let filename = format!(
"{}_error.txt",
timestamp.format("%Y%m%d_%H%M%S%.3f")
);
let filename = format!("{}_error.txt", timestamp.format("%Y%m%d_%H%M%S%.3f"));
let file_path = self.log_dir.join(filename);
let log_entry = format!(
"=== Error at {} ===\nEndpoint: {}\n\n{}\n",
timestamp.to_rfc3339(),
@@ -250,13 +250,10 @@ impl DebugLogger {
}
let timestamp = Local::now();
let filename = format!(
"{}_sse.json",
timestamp.format("%Y%m%d_%H%M%S%.3f")
);
let filename = format!("{}_sse.json", timestamp.format("%Y%m%d_%H%M%S%.3f"));
let file_path = self.log_dir.join(filename);
let log_entry = serde_json::json!({
"timestamp": timestamp.to_rfc3339(),
"type": "sse_event",
@@ -269,4 +266,4 @@ impl DebugLogger {
Ok(())
}
}
}
+6 -11
View File
@@ -27,34 +27,29 @@ pub fn compute_image_hash<P: AsRef<Path>>(path: P) -> anyhow::Result<(Vec<u8>, V
let img = ImageReader::open(path)?.decode()?;
let phash = phash_256(&img);
let dhash = dhash_256(&img);
Ok((phash.as_bytes().to_vec(), dhash.as_bytes().to_vec()))
}
/// Compare image hashes to determine if images are similar
pub fn are_hashes_similar(
phash1: &[u8],
dhash1: &[u8],
phash2: &[u8],
dhash2: &[u8],
) -> bool {
pub fn are_hashes_similar(phash1: &[u8], dhash1: &[u8], phash2: &[u8], dhash2: &[u8]) -> bool {
if phash1.len() != 32 || dhash1.len() != 32 || phash2.len() != 32 || dhash2.len() != 32 {
return false;
}
// Count differing bits (Hamming distance)
let phash_dist = phash1
.iter()
.zip(phash2.iter())
.map(|(a, b)| (a ^ b).count_ones() as u32)
.sum::<u32>();
let dhash_dist = dhash1
.iter()
.zip(dhash2.iter())
.map(|(a, b)| (a ^ b).count_ones() as u32)
.sum::<u32>();
// 256 bits → ~5% tolerance (≈13 bits)
phash_dist <= 13 && dhash_dist <= 13
}
}
+14 -4
View File
@@ -29,9 +29,16 @@ pub enum ResponseInputItem {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentItem {
InputText { text: String },
InputImage { image_url: String, detail: Option<String> },
OutputText { text: String },
InputText {
text: String,
},
InputImage {
image_url: String,
detail: Option<String>,
},
OutputText {
text: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -164,7 +171,10 @@ impl From<Vec<InputItem>> for ResponseInputItem {
content_items.push(ContentItem::InputText { text });
}
InputItem::Image { image_url } => {
content_items.push(ContentItem::InputImage { image_url, detail: None });
content_items.push(ContentItem::InputImage {
image_url,
detail: None,
});
}
InputItem::LocalImage { path } => match std::fs::read(&path) {
Ok(bytes) => {
+14 -4
View File
@@ -928,25 +928,35 @@ fn create_browser_move_tool() -> OpenAiTool {
properties.insert(
"x".to_string(),
JsonSchema::Number {
description: Some("The absolute X coordinate to move the mouse to (use with y)".to_string()),
description: Some(
"The absolute X coordinate to move the mouse to (use with y)".to_string(),
),
},
);
properties.insert(
"y".to_string(),
JsonSchema::Number {
description: Some("The absolute Y coordinate to move the mouse to (use with x)".to_string()),
description: Some(
"The absolute Y coordinate to move the mouse to (use with x)".to_string(),
),
},
);
properties.insert(
"dx".to_string(),
JsonSchema::Number {
description: Some("Relative (+/-) X movement in CSS pixels from current mouse position (use with dy)".to_string()),
description: Some(
"Relative (+/-) X movement in CSS pixels from current mouse position (use with dy)"
.to_string(),
),
},
);
properties.insert(
"dy".to_string(),
JsonSchema::Number {
description: Some("Relative (+/-) Y movement in CSS pixels from current mouse position (use with dx)".to_string()),
description: Some(
"Relative (+/-) Y movement in CSS pixels from current mouse position (use with dx)"
.to_string(),
),
},
);
+1 -1
View File
@@ -48,7 +48,7 @@ pub struct Cli {
/// Tell the agent to use the specified directory as its working root.
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Enable debug logging of all LLM requests and responses to files.
#[clap(long = "debug", short = 'd', default_value_t = false)]
pub debug: bool,
@@ -378,6 +378,7 @@ fn derive_config_from_params(
include_plan_tool,
disable_response_storage: None,
show_raw_agent_reasoning: None,
debug: None,
};
let cli_overrides = cli_overrides
+142 -141
View File
@@ -60,7 +60,7 @@ pub(crate) struct App<'a> {
pending_redraw: Arc<AtomicBool>,
enhanced_keys_supported: bool,
/// Debug flag for logging LLM requests/responses
_debug: bool,
@@ -368,156 +368,157 @@ impl App<'_> {
command_text.clone()
}
};
match command {
SlashCommand::New => {
// User accepted – switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
SlashCommand::New => {
// User accepted – switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str =
include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
}
}
}
SlashCommand::Quit => {
break;
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
break;
}
SlashCommand::Diff => {
let (is_git_repo, diff_text) = match get_git_diff() {
Ok(v) => v,
Err(e) => {
let msg = format!("Failed to compute diff: {e}");
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(msg);
SlashCommand::Quit => {
break;
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
break;
}
SlashCommand::Diff => {
let (is_git_repo, diff_text) = match get_git_diff() {
Ok(v) => v,
Err(e) => {
let msg = format!("Failed to compute diff: {e}");
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(msg);
}
continue;
}
continue;
}
};
if let AppState::Chat { widget } = &mut self.app_state {
let text = if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
};
widget.add_diff_output(text);
}
}
SlashCommand::Mention => {
// The mention feature is handled differently in our fork
// For now, just add @ to the composer
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
}
}
SlashCommand::Theme => {
// Theme selection is handled in submit_user_message
// This case is here for completeness
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_theme_selection();
}
}
SlashCommand::Prompts => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_prompts_output();
}
}
// Prompt-expanding commands should have been handled in submit_user_message
// but add a fallback just in case
SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code => {
// These should have been expanded already, but handle them anyway
if let AppState::Chat { widget } = &mut self.app_state {
let expanded = command.expand_prompt(&command_text);
if let Some(prompt) = expanded {
widget.submit_text_message(prompt);
if let AppState::Chat { widget } = &mut self.app_state {
let text = if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
};
widget.add_diff_output(text);
}
}
}
SlashCommand::Browser => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_browser_command(command_args);
SlashCommand::Mention => {
// The mention feature is handled differently in our fork
// For now, just add @ to the composer
if let AppState::Chat { widget } = &mut self.app_state {
widget.insert_str("@");
}
}
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
}
}
SlashCommand::Theme => {
// Theme selection is handled in submit_user_message
// This case is here for completeness
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_theme_selection();
}
}
SlashCommand::Prompts => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_prompts_output();
}
}
// Prompt-expanding commands should have been handled in submit_user_message
// but add a fallback just in case
SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code => {
// These should have been expanded already, but handle them anyway
if let AppState::Chat { widget } = &mut self.app_state {
let expanded = command.expand_prompt(&command_text);
if let Some(prompt) = expanded {
widget.submit_text_message(prompt);
}
}
}
SlashCommand::Browser => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_browser_command(command_args);
}
}
SlashCommand::Chrome => {
if let AppState::Chat { widget } = &mut self.app_state {
// Call the dedicated chrome command handler
widget.handle_chrome_command(command_args);
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::FileChange;
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
),
}));
}
}
SlashCommand::Chrome => {
if let AppState::Chat { widget } = &mut self.app_state {
// Call the dedicated chrome command handler
widget.handle_chrome_command(command_args);
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::FileChange;
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
),
}));
}
}
},
}
AppEvent::PrepareAgents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.prepare_agents();
+2 -2
View File
@@ -78,10 +78,10 @@ pub(crate) enum AppEvent {
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
/// Show Chrome launch options dialog
ShowChromeOptions(Option<u16>),
/// Chrome launch option selected by user
ChromeLaunchOptionSelected(ChromeLaunchOption, Option<u16>),
}
@@ -979,8 +979,10 @@ impl WidgetRef for &ChatComposer {
// Mix bits so low bits aren't parity-biased
fn mix(mut x: u64) -> u64 {
x ^= x >> 30; x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27; x = x.wrapping_mul(0x94d049bb133111eb);
x ^= x >> 30;
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27;
x = x.wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
@@ -992,7 +994,7 @@ impl WidgetRef for &ChatComposer {
let selected_spinner: &[char] = if r < 2 {
&['✨']
} else if r < 101 {
&['✧','✦','✧']
&['✧', '✦', '✧']
} else {
match r % 3 {
0 => &['✶'],
@@ -1004,7 +1006,8 @@ impl WidgetRef for &ChatComposer {
let frame_idx = (SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() / 150) as usize;
.as_millis()
/ 150) as usize;
let spinner = selected_spinner[frame_idx % selected_spinner.len()];
@@ -86,19 +86,21 @@ impl ChromeSelectionView {
fn confirm_selection(&mut self) {
let options = Self::get_options();
let selected = options[self.selected_index].0;
// Send the selected option event
self.app_event_tx
.send(AppEvent::ChromeLaunchOptionSelected(selected, self.port));
self.is_complete = true;
}
fn cancel(&mut self) {
// Send cancel event
self.app_event_tx
.send(AppEvent::ChromeLaunchOptionSelected(ChromeLaunchOption::Cancel, self.port));
self.app_event_tx.send(AppEvent::ChromeLaunchOptionSelected(
ChromeLaunchOption::Cancel,
self.port,
));
self.is_complete = true;
}
}
@@ -142,14 +144,14 @@ impl<'a> BottomPaneView<'a> for ChromeSelectionView {
block.render(area, buf);
let mut lines = Vec::new();
// Add header
lines.push(Line::from(vec![
Span::styled(
"Chrome is already running or CDP connection failed",
Style::default().fg(crate::colors::warning()).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![Span::styled(
"Chrome is already running or CDP connection failed",
Style::default()
.fg(crate::colors::warning())
.add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(Line::from("Select an option:"));
lines.push(Line::from(""));
@@ -158,37 +160,29 @@ impl<'a> BottomPaneView<'a> for ChromeSelectionView {
let options = Self::get_options();
for (i, (_, label, description)) in options.iter().enumerate() {
let is_selected = i == self.selected_index;
if is_selected {
// Highlighted option
lines.push(Line::from(vec![
Span::styled(
format!("▶ {}", label),
Style::default()
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(
format!(" {}", description),
Style::default().fg(crate::colors::secondary()),
),
]));
lines.push(Line::from(vec![Span::styled(
format!("▶ {}", label),
Style::default()
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" {}", description),
Style::default().fg(crate::colors::secondary()),
)]));
} else {
// Non-selected option
lines.push(Line::from(vec![
Span::styled(
format!(" {}", label),
Style::default().fg(crate::colors::text()),
),
]));
lines.push(Line::from(vec![
Span::styled(
format!(" {}", description),
Style::default().fg(crate::colors::text_dim()),
),
]));
lines.push(Line::from(vec![Span::styled(
format!(" {}", label),
Style::default().fg(crate::colors::text()),
)]));
lines.push(Line::from(vec![Span::styled(
format!(" {}", description),
Style::default().fg(crate::colors::text_dim()),
)]));
}
}
@@ -208,12 +202,11 @@ impl<'a> BottomPaneView<'a> for ChromeSelectionView {
),
]));
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left);
let paragraph = Paragraph::new(lines).alignment(Alignment::Left);
paragraph.render(inner, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
20 // Fixed height for the selection dialog
}
}
}
+1 -1
View File
@@ -313,7 +313,7 @@ impl BottomPane<'_> {
self.status_view_active = false;
self.request_redraw()
}
/// Show the Chrome launch options UI
pub fn show_chrome_selection(&mut self, port: Option<u16>) {
use chrome_selection_view::ChromeSelectionView;
+168 -122
View File
@@ -5,9 +5,9 @@ use std::sync::Mutex;
use ratatui::style::Modifier;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::ConversationManager;
use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -19,6 +19,8 @@ use codex_core::protocol::AgentStatusUpdateEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::BrowserScreenshotUpdateEvent;
use codex_core::protocol::CustomToolCallBeginEvent;
use codex_core::protocol::CustomToolCallEndEvent;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
@@ -26,8 +28,6 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::InputItem;
use codex_core::protocol::CustomToolCallBeginEvent;
use codex_core::protocol::CustomToolCallEndEvent;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op;
@@ -432,7 +432,10 @@ impl ChatWidget<'_> {
tokio::spawn(async move {
// Use ConversationManager to properly handle authentication
let conversation_manager = ConversationManager::default();
let new_conversation = match conversation_manager.new_conversation(config_for_agent_loop).await {
let new_conversation = match conversation_manager
.new_conversation(config_for_agent_loop)
.await
{
Ok(conv) => conv,
Err(e) => {
// TODO: surface this error to the user.
@@ -680,13 +683,17 @@ impl ChatWidget<'_> {
// Check for duplicate text lines to prevent adding the same content twice
if let Some(last_cell) = self.history_cells.last() {
// If both are text lines with identical content, skip adding the duplicate
if let (HistoryCell::StyledText { view: last_view }, HistoryCell::StyledText { view: new_view }) = (last_cell, &cell) {
if let (
HistoryCell::StyledText { view: last_view },
HistoryCell::StyledText { view: new_view },
) = (last_cell, &cell)
{
if last_view.lines == new_view.lines {
return; // Skip duplicate
}
}
}
// If this is the first user prompt, start fade-out animation for AnimatedWelcome
if matches!(cell, HistoryCell::UserPrompt { .. }) {
let has_existing_user_prompts = self
@@ -820,9 +827,10 @@ impl ChatWidget<'_> {
// Add timeout to screenshot capture
let capture_result = tokio::time::timeout(
tokio::time::Duration::from_secs(10),
browser_manager.capture_screenshot_with_url()
).await;
browser_manager.capture_screenshot_with_url(),
)
.await;
match capture_result {
Ok(Ok((screenshot_paths, url))) => {
tracing::info!(
@@ -892,9 +900,12 @@ impl ChatWidget<'_> {
"Screenshot capture timed out on attempt {} - browser may be disconnected",
attempts
);
if attempts >= max_attempts {
tracing::error!("Giving up after {} timeout attempts", max_attempts);
tracing::error!(
"Giving up after {} timeout attempts",
max_attempts
);
break;
} else {
// Try to reconnect the browser before next attempt
@@ -902,13 +913,13 @@ impl ChatWidget<'_> {
if let Err(e) = browser_manager.close().await {
tracing::warn!("Error closing browser: {}", e);
}
// Wait a bit before reconnection
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Browser will auto-reconnect on next capture attempt
tracing::info!("Will retry screenshot capture after reconnection");
// Exponential backoff
let wait_time =
std::time::Duration::from_millis(1000 * (1 << (attempts - 1)));
@@ -1057,7 +1068,7 @@ impl ChatWidget<'_> {
return;
}
}
// Fallback: Check if we've already processed this message (duplicate detection)
let message_trimmed = message.trim();
if !message_trimmed.is_empty() {
@@ -1068,9 +1079,11 @@ impl ChatWidget<'_> {
return;
}
}
// Also check against the current answer_buffer
if !self.answer_buffer.is_empty() && self.answer_buffer.trim() == message_trimmed {
if !self.answer_buffer.is_empty()
&& self.answer_buffer.trim() == message_trimmed
{
// We've already streamed this content via deltas
// Just finalize the stream without adding duplicate content
if self.current_stream == Some(StreamKind::Answer) {
@@ -1083,7 +1096,7 @@ impl ChatWidget<'_> {
return;
}
}
// AgentMessage: if no deltas were streamed, render the final text once.
if self.current_stream != Some(StreamKind::Answer) && !message.is_empty() {
self.begin_stream(StreamKind::Answer);
@@ -1144,10 +1157,11 @@ impl ChatWidget<'_> {
return;
}
}
// Fallback: Check if we've already streamed this content via deltas
let text_trimmed = text.trim();
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed {
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed
{
// We've already streamed this content via deltas
// Just finalize the stream without adding duplicate content
if self.current_stream == Some(StreamKind::Reasoning) {
@@ -1158,7 +1172,7 @@ impl ChatWidget<'_> {
self.request_redraw();
return;
}
// Final reasoning: if no deltas were streamed, render the final text.
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
self.begin_stream(StreamKind::Reasoning);
@@ -1202,10 +1216,11 @@ impl ChatWidget<'_> {
return;
}
}
// Fallback: Check if we've already streamed this content via deltas
let text_trimmed = text.trim();
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed {
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed
{
// We've already streamed this content via deltas
// Just finalize the stream without adding duplicate content
if self.current_stream == Some(StreamKind::Reasoning) {
@@ -1216,7 +1231,7 @@ impl ChatWidget<'_> {
self.request_redraw();
return;
}
// Final raw reasoning: if no deltas were streamed, render the final text.
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
self.begin_stream(StreamKind::Reasoning);
@@ -1262,7 +1277,7 @@ impl ChatWidget<'_> {
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
// Reset with max width to disable wrapping
self.live_builder = RowBuilder::new(usize::MAX);
self.live_builder = RowBuilder::new(usize::MAX);
self.current_stream = None;
self.stream_header_emitted = false;
self.answer_buffer.clear();
@@ -1412,8 +1427,7 @@ impl ChatWidget<'_> {
}) => {
self.finalize_active_stream();
self.add_to_history(HistoryCell::new_active_custom_tool_call(
tool_name,
parameters,
tool_name, parameters,
));
}
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
@@ -1424,11 +1438,7 @@ impl ChatWidget<'_> {
result,
}) => {
self.add_to_history(HistoryCell::new_completed_custom_tool_call(
80,
tool_name,
parameters,
duration,
result,
80, tool_name, parameters, duration, result,
));
}
EventMsg::GetHistoryEntryResponse(event) => {
@@ -1850,12 +1860,16 @@ impl ChatWidget<'_> {
pub(crate) fn show_chrome_options(&mut self, port: Option<u16>) {
self.bottom_pane.show_chrome_selection(port);
}
pub(crate) fn handle_chrome_launch_option(&mut self, option: crate::bottom_pane::chrome_selection_view::ChromeLaunchOption, port: Option<u16>) {
pub(crate) fn handle_chrome_launch_option(
&mut self,
option: crate::bottom_pane::chrome_selection_view::ChromeLaunchOption,
port: Option<u16>,
) {
use crate::bottom_pane::chrome_selection_view::ChromeLaunchOption;
let launch_port = port.unwrap_or(9222);
match option {
ChromeLaunchOption::CloseAndUseProfile => {
// Kill existing Chrome and launch with user profile
@@ -1903,15 +1917,17 @@ impl ChatWidget<'_> {
}
}
}
fn launch_chrome_with_profile(&mut self, port: u16) {
use std::process::Stdio;
use ratatui::text::Line;
use std::process::Stdio;
#[cfg(target_os = "macos")]
{
let log_path = format!("{}/coder-chrome.log", std::env::temp_dir().display());
let mut cmd = std::process::Command::new("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
let mut cmd = std::process::Command::new(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
);
cmd.arg(format!("--remote-debugging-port={}", port))
.arg("--no-first-run")
.arg("--no-default-browser-check")
@@ -1931,7 +1947,7 @@ impl ChatWidget<'_> {
.stdin(Stdio::null());
let _ = cmd.spawn();
}
#[cfg(target_os = "linux")]
{
let log_path = format!("{}/coder-chrome.log", std::env::temp_dir().display());
@@ -1955,17 +1971,19 @@ impl ChatWidget<'_> {
.stdin(Stdio::null());
let _ = cmd.spawn();
}
#[cfg(target_os = "windows")]
{
let log_path = format!("{}\\coder-chrome.log", std::env::temp_dir().display());
let chrome_paths = vec![
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
format!("{}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
std::env::var("USERPROFILE").unwrap_or_default()),
format!(
"{}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
std::env::var("USERPROFILE").unwrap_or_default()
),
];
for chrome_path in chrome_paths {
if std::path::Path::new(&chrome_path).exists() {
let mut cmd = std::process::Command::new(&chrome_path);
@@ -1991,27 +2009,27 @@ impl ChatWidget<'_> {
}
}
}
// Add status message
self.add_to_history(HistoryCell::BackgroundEvent {
view: TextBlock::new(vec![Line::from("✅ Chrome launched with user profile")]),
});
}
fn connect_to_chrome_after_launch(&mut self, port: u16) {
// Wait a moment for Chrome to start, then reuse the existing connection logic
let app_event_tx = self.app_event_tx.clone();
let latest_screenshot = self.latest_browser_screenshot.clone();
tokio::spawn(async move {
// Wait for Chrome to fully start
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Now try to connect using the shared CDP connection logic
ChatWidget::connect_to_cdp_chrome(Some(port), latest_screenshot, app_event_tx).await;
});
}
/// Shared CDP connection logic used by both /chrome command and Chrome launch options
async fn connect_to_cdp_chrome(
port: Option<u16>,
@@ -2034,52 +2052,62 @@ impl ChatWidget<'_> {
match browser_manager.connect_to_chrome_only().await {
Ok(_) => {
tracing::info!("Connected to Chrome via CDP");
// Send success message
let success_msg = if let Some(p) = port {
format!("✅ Connected to Chrome on port {}", p)
} else {
"✅ Connected to Chrome (auto-detected port)".to_string()
};
// Set up navigation callback
let latest_screenshot_callback = latest_screenshot.clone();
let app_event_tx_callback = app_event_tx.clone();
browser_manager.set_navigation_callback(move |url| {
tracing::info!("CDP Navigation callback triggered for URL: {}", url);
let latest_screenshot_inner = latest_screenshot_callback.clone();
let app_event_tx_inner = app_event_tx_callback.clone();
let url_inner = url.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let browser_manager_inner = ChatWidget::get_browser_manager().await;
match browser_manager_inner.capture_screenshot_with_url().await {
Ok((paths, _)) => {
if let Some(first_path) = paths.first() {
tracing::info!("Auto-captured CDP screenshot: {}", first_path.display());
if let Ok(mut latest) = latest_screenshot_inner.lock() {
*latest = Some((first_path.clone(), url_inner.clone()));
browser_manager
.set_navigation_callback(move |url| {
tracing::info!("CDP Navigation callback triggered for URL: {}", url);
let latest_screenshot_inner = latest_screenshot_callback.clone();
let app_event_tx_inner = app_event_tx_callback.clone();
let url_inner = url.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let browser_manager_inner = ChatWidget::get_browser_manager().await;
match browser_manager_inner.capture_screenshot_with_url().await {
Ok((paths, _)) => {
if let Some(first_path) = paths.first() {
tracing::info!(
"Auto-captured CDP screenshot: {}",
first_path.display()
);
if let Ok(mut latest) = latest_screenshot_inner.lock() {
*latest = Some((first_path.clone(), url_inner.clone()));
}
use codex_core::protocol::{
BrowserScreenshotUpdateEvent, Event, EventMsg,
};
let _ =
app_event_tx_inner.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BrowserScreenshotUpdate(
BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url_inner,
},
),
}));
}
use codex_core::protocol::{BrowserScreenshotUpdateEvent, EventMsg, Event};
let _ = app_event_tx_inner.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url_inner,
}),
}));
}
Err(e) => {
tracing::error!("Failed to auto-capture CDP screenshot: {}", e);
}
}
Err(e) => {
tracing::error!("Failed to auto-capture CDP screenshot: {}", e);
}
}
});
}).await;
});
})
.await;
// Set as global manager
codex_browser::global::set_global_browser_manager(browser_manager.clone()).await;
@@ -2089,22 +2117,29 @@ impl ChatWidget<'_> {
match browser_manager.capture_screenshot_with_url().await {
Ok((paths, url)) => {
if let Some(first_path) = paths.first() {
tracing::info!("Initial CDP screenshot captured: {}", first_path.display());
tracing::info!(
"Initial CDP screenshot captured: {}",
first_path.display()
);
if let Ok(mut latest) = latest_screenshot.lock() {
*latest = Some((
first_path.clone(),
url.clone().unwrap_or_else(|| "Chrome".to_string()),
));
}
use codex_core::protocol::{BrowserScreenshotUpdateEvent, EventMsg, Event};
use codex_core::protocol::{
BrowserScreenshotUpdateEvent, Event, EventMsg,
};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url.unwrap_or_else(|| "Chrome".to_string()),
}),
msg: EventMsg::BrowserScreenshotUpdate(
BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url.unwrap_or_else(|| "Chrome".to_string()),
},
),
}));
}
}
@@ -2112,9 +2147,9 @@ impl ChatWidget<'_> {
tracing::error!("Failed to capture initial CDP screenshot: {}", e);
}
}
// Send success status to chat
use codex_core::protocol::{EventMsg, BackgroundEventEvent, Event};
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
@@ -2124,10 +2159,10 @@ impl ChatWidget<'_> {
}
Err(e) => {
tracing::error!("Failed to connect to Chrome: {}", e);
// Send error message only - don't show dialog again since we're already
// in the post-launch connection attempt
use codex_core::protocol::{BackgroundEventEvent, EventMsg, Event};
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
@@ -2137,18 +2172,20 @@ impl ChatWidget<'_> {
}
}
}
fn launch_chrome_with_temp_profile(&mut self, port: u16) {
use std::process::Stdio;
use ratatui::text::Line;
use std::process::Stdio;
let temp_dir = std::env::temp_dir();
let profile_dir = temp_dir.join(format!("coder-chrome-temp-{}", port));
#[cfg(target_os = "macos")]
{
let log_path = format!("{}/coder-chrome.log", std::env::temp_dir().display());
let mut cmd = std::process::Command::new("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
let mut cmd = std::process::Command::new(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
);
cmd.arg(format!("--remote-debugging-port={}", port))
.arg(format!("--user-data-dir={}", profile_dir.display()))
.arg("--no-first-run")
@@ -2169,7 +2206,7 @@ impl ChatWidget<'_> {
.stdin(Stdio::null());
let _ = cmd.spawn();
}
#[cfg(target_os = "linux")]
{
let log_path = format!("{}/coder-chrome.log", std::env::temp_dir().display());
@@ -2194,17 +2231,19 @@ impl ChatWidget<'_> {
.stdin(Stdio::null());
let _ = cmd.spawn();
}
#[cfg(target_os = "windows")]
{
let log_path = format!("{}\\coder-chrome.log", std::env::temp_dir().display());
let chrome_paths = vec![
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
format!("{}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
std::env::var("USERPROFILE").unwrap_or_default()),
format!(
"{}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
std::env::var("USERPROFILE").unwrap_or_default()
),
];
for chrome_path in chrome_paths {
if std::path::Path::new(&chrome_path).exists() {
let mut cmd = std::process::Command::new(&chrome_path);
@@ -2231,17 +2270,20 @@ impl ChatWidget<'_> {
}
}
}
// Add status message
self.add_to_history(HistoryCell::BackgroundEvent {
view: TextBlock::new(vec![Line::from(format!("✅ Chrome launched with temporary profile at {}", profile_dir.display()))]),
view: TextBlock::new(vec![Line::from(format!(
"✅ Chrome launched with temporary profile at {}",
profile_dir.display()
))]),
});
}
pub(crate) fn handle_browser_command(&mut self, command_text: String) {
// Parse the browser subcommand
let trimmed = command_text.trim();
// Handle the case where just "/browser" was typed
if trimmed.is_empty() {
let response = "Browser commands:\n• /browser <url> - Open URL in internal browser\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\n\nUse /chrome [port] to connect to external Chrome browser";
@@ -2254,7 +2296,7 @@ impl ChatWidget<'_> {
});
return;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
let response = if !parts.is_empty() {
let first_arg = parts[0];
@@ -2277,7 +2319,7 @@ impl ChatWidget<'_> {
let latest_screenshot = self.latest_browser_screenshot.clone();
let app_event_tx = self.app_event_tx.clone();
let url_for_goto = full_url.clone();
// Add status message
let status_msg = format!("🌐 Opening internal browser: {}", full_url);
self.add_to_history(HistoryCell::BackgroundEvent {
@@ -2430,9 +2472,9 @@ impl ChatWidget<'_> {
result.url,
result.title
);
// Send success message to chat
use codex_core::protocol::{EventMsg, BackgroundEventEvent};
use codex_core::protocol::{BackgroundEventEvent, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
@@ -2619,7 +2661,7 @@ impl ChatWidget<'_> {
pub(crate) fn handle_chrome_command(&mut self, command_text: String) {
// Parse the chrome command arguments
let parts: Vec<&str> = command_text.trim().split_whitespace().collect();
// Check if it's a status command
if !parts.is_empty() && parts[0] == "status" {
// Get status from BrowserManager - same as /browser status
@@ -2632,7 +2674,7 @@ impl ChatWidget<'_> {
let status = status_rx
.recv()
.unwrap_or_else(|_| "Failed to get browser status.".to_string());
// Add the response to the UI
let lines = status
.lines()
@@ -2643,7 +2685,7 @@ impl ChatWidget<'_> {
});
return;
}
// Extract port if provided (number as first argument)
let port = if !parts.is_empty() {
parts[0].parse::<u16>().ok()
@@ -2657,7 +2699,10 @@ impl ChatWidget<'_> {
let launch_port = port.unwrap_or(9222);
// Add status message to chat
let status_msg = format!("🔗 Connecting to Chrome DevTools Protocol (port: {})...", port_display);
let status_msg = format!(
"🔗 Connecting to Chrome DevTools Protocol (port: {})...",
port_display
);
self.add_to_history(HistoryCell::BackgroundEvent {
view: TextBlock::new(vec![Line::from(status_msg)]),
});
@@ -3582,12 +3627,12 @@ impl WidgetRef for &ChatWidget<'_> {
let mut total_height = 0u16;
let mut item_heights = Vec::new();
let spacing = 1u16; // Add 1 line of spacing between each history cell
for (idx, item) in all_content.iter().enumerate() {
let h = item.desired_height(content_area.width);
item_heights.push(h);
total_height += h;
// Add spacing after each item except the last one
if idx < all_content.len() - 1 {
total_height += spacing;
@@ -3699,13 +3744,14 @@ impl WidgetRef for &ChatWidget<'_> {
}
content_y += item_height;
// Add spacing after this item (except for the last item)
if idx < all_content.len() - 1 {
content_y += spacing;
// Also advance screen_y by the visible portion of the spacing
if content_y > scroll_pos && screen_y < content_area.y + content_area.height {
screen_y += spacing.min((content_area.y + content_area.height).saturating_sub(screen_y));
screen_y += spacing
.min((content_area.y + content_area.height).saturating_sub(screen_y));
}
}
}
+8 -6
View File
@@ -167,9 +167,7 @@ pub(crate) fn create_diff_summary(
fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
// Use terminal width or a reasonable fallback
let term_cols: usize = terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(120);
let term_cols: usize = terminal::size().map(|(w, _)| w as usize).unwrap_or(120);
for (index, (path, change)) in changes.iter().enumerate() {
let is_first_file = index == 0;
@@ -424,10 +422,14 @@ mod tests {
// Call the wrapping function directly so we can precisely control the width
// Use a fixed width for testing wrapping behavior
const TEST_WRAP_WIDTH: usize = 80;
let lines =
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, TEST_WRAP_WIDTH);
let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, TEST_WRAP_WIDTH);
// Render into a small terminal to capture the visual layout
snapshot_lines("wrap_behavior_insert", lines, (TEST_WRAP_WIDTH + 10) as u16, 8);
snapshot_lines(
"wrap_behavior_insert",
lines,
(TEST_WRAP_WIDTH + 10) as u16,
8,
);
}
}
+29 -22
View File
@@ -27,7 +27,6 @@ use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
use mcp_types::ResourceLink;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
@@ -503,7 +502,10 @@ impl HistoryCell {
let prefix = if j == 0 { first_prefix } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
Span::styled(line_text.to_string(), Style::default().fg(Color::LightBlue)),
Span::styled(
line_text.to_string(),
Style::default().fg(crate::colors::info()),
),
]));
}
}
@@ -556,9 +558,9 @@ impl HistoryCell {
parameters: Option<serde_json::Value>,
) -> Self {
let title_line = Line::from(vec![tool_name.bold(), " running...".dim()]);
let mut lines: Vec<Line> = vec![title_line];
// Add parameters if present
if let Some(params) = parameters {
if let Ok(formatted) = serde_json::to_string_pretty(&params) {
@@ -692,7 +694,9 @@ impl HistoryCell {
lines.push(Line::from(vec![
Span::styled(
"Error: ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
Style::default()
.fg(crate::colors::error())
.add_modifier(Modifier::BOLD),
),
Span::raw(e),
]));
@@ -715,7 +719,7 @@ impl HistoryCell {
let duration_str = format_duration(duration);
let success = result.is_ok();
let status_str = if success { "success" } else { "failed" };
let title_line = Line::from(vec![
tool_name.bold(),
" ".into(),
@@ -733,7 +737,8 @@ impl HistoryCell {
// Add result
match result {
Ok(msg) => {
let truncated = format_and_truncate_tool_result(&msg, TOOL_CALL_MAX_LINES, num_cols);
let truncated =
format_and_truncate_tool_result(&msg, TOOL_CALL_MAX_LINES, num_cols);
for line in truncated.lines() {
lines.push(Line::from(format!("> {}", line)));
}
@@ -965,7 +970,7 @@ impl HistoryCell {
if filled > 0 {
header.push(Span::styled(
"█".repeat(filled),
Style::default().fg(Color::Green),
Style::default().fg(crate::colors::success()),
));
}
if empty > 0 {
@@ -996,7 +1001,7 @@ impl HistoryCell {
for (idx, PlanItemArg { step, status }) in plan.into_iter().enumerate() {
let (box_span, text_span) = match status {
StepStatus::Completed => (
Span::styled("✔", Style::default().fg(Color::Green)),
Span::styled("✔", Style::default().fg(crate::colors::success())),
Span::styled(
step,
Style::default().add_modifier(Modifier::CROSSED_OUT | Modifier::DIM),
@@ -1007,7 +1012,7 @@ impl HistoryCell {
Span::styled(
step,
Style::default()
.fg(Color::Blue)
.fg(crate::colors::info())
.add_modifier(Modifier::BOLD),
),
),
@@ -1345,8 +1350,9 @@ impl HistoryCell {
| HistoryCell::CompletedCustomToolCall { .. } => {
// Use the same processed/plain lines as desired_height/render_ref but apply a vertical scroll.
let lines = match self {
HistoryCell::BackgroundEvent { .. }
| HistoryCell::DimmedReasoning { .. } => self.get_processed_lines(area.width),
HistoryCell::BackgroundEvent { .. } | HistoryCell::DimmedReasoning { .. } => {
self.get_processed_lines(area.width)
}
HistoryCell::StyledText { view }
| HistoryCell::WelcomeMessage { view }
| HistoryCell::GitDiffOutput { view }
@@ -1400,26 +1406,24 @@ impl HistoryCell {
HistoryCell::Exec(_) | HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => {
// Use ScrollView for complex widgets that don't support native scrolling
let content_height = self.desired_height(area.width);
// Create a wrapper that implements Widget
#[derive(Clone)]
struct CellRenderer<'a> {
cell: &'a HistoryCell,
}
impl<'a> Widget for CellRenderer<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
self.cell.render_ref(area, buf);
}
}
// Apply ScrollView with the vertical skip
let scroll_view = ScrollView::new(
CellRenderer { cell: self },
content_height as usize,
)
.scroll_y(skip_top as usize);
let scroll_view =
ScrollView::new(CellRenderer { cell: self }, content_height as usize)
.scroll_y(skip_top as usize);
scroll_view.render(area, buf);
}
}
@@ -1437,7 +1441,10 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
.unwrap_or_default();
let invocation_spans = vec![
Span::styled(invocation.server.clone(), Style::default().fg(Color::Blue)),
Span::styled(
invocation.server.clone(),
Style::default().fg(crate::colors::info()),
),
Span::raw("."),
Span::styled(invocation.tool.clone(), Style::default().fg(Color::Blue)),
Span::raw("("),
+27 -6
View File
@@ -462,7 +462,8 @@ mod tests {
)
.unwrap();
assert_eq!(
actual, expected,
actual,
expected,
"Actual: {}",
String::from_utf8_lossy(&actual)
);
@@ -486,13 +487,33 @@ mod tests {
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].spans.len(), 2);
assert_eq!(wrapped[0].spans[0].content, "hello ");
assert!(!wrapped[0].spans[0].style.add_modifier.contains(Modifier::BOLD));
assert!(
!wrapped[0].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert_eq!(wrapped[0].spans[1].content, "wo");
assert!(wrapped[0].spans[1].style.add_modifier.contains(Modifier::BOLD));
assert!(
wrapped[0].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert_eq!(wrapped[1].spans.len(), 2);
assert_eq!(wrapped[1].spans[0].content, "rld");
assert!(wrapped[1].spans[0].style.add_modifier.contains(Modifier::BOLD));
assert!(
wrapped[1].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert_eq!(wrapped[1].spans[1].content, " foo");
assert!(!wrapped[1].spans[1].style.add_modifier.contains(Modifier::BOLD));
assert!(
!wrapped[1].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
}
}
+13 -2
View File
@@ -285,8 +285,19 @@ fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config);
let Cli { prompt, images, debug, .. } = cli;
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen, debug);
let Cli {
prompt,
images,
debug,
..
} = cli;
let mut app = App::new(
config.clone(),
prompt,
images,
should_show_trust_screen,
debug,
);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{
@@ -8,7 +8,6 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
@@ -116,7 +115,7 @@ impl WidgetRef for &TrustDirectoryWidget {
}
lines.push(Line::from(""));
if let Some(error) = &self.error {
lines.push(Line::from(format!(" {error}")).fg(Color::Red));
lines.push(Line::from(format!(" {error}")).fg(crate::colors::error()));
lines.push(Line::from(""));
}
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
+5 -3
View File
@@ -1,8 +1,8 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
style::Style,
widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
};
/// A generic scrollable viewport for any Widget.
@@ -87,7 +87,9 @@ where
// Clamp scroll positions
let max_y = self.content_height.saturating_sub(viewport_h);
let sy = self.scroll_y.min(max_y);
let sx = self.scroll_x.min(self.content_width.unwrap_or(0).saturating_sub(viewport_w));
let sx = self
.scroll_x
.min(self.content_width.unwrap_or(0).saturating_sub(viewport_w));
// Offscreen render: pretend we have a huge height = content_height
// (Width = viewport width so we don't waste memory horizontally)
@@ -134,4 +136,4 @@ where
.render(inner_area, buf, &mut state);
}
}
}
}
+6 -5
View File
@@ -66,7 +66,9 @@ pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec<Span<'static>>
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
color_for_level(level)
Style::default()
.fg(color_for_level(level))
.add_modifier(Modifier::BOLD)
};
spans.push(Span::styled(ch.to_string(), style));
}
@@ -74,12 +76,11 @@ pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec<Span<'static>>
}
fn color_for_level(level: u8) -> Color {
// For shimmer effect, we need to use grayscale colors
// Theme colors wouldn't work well here as they're not grayscale
// For shimmer effect, we map brightness levels to theme colors
if level < 128 {
Color::DarkGray
crate::colors::text_dim()
} else if level < 192 {
Color::Gray
crate::colors::text()
} else {
crate::colors::text_bright()
}
+6 -6
View File
@@ -121,7 +121,7 @@ impl UserApprovalWidget<'_> {
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
let mut contents: Vec<Line> = vec![
Line::from(vec![
"? ".fg(Color::Blue),
"? ".fg(crate::colors::info()),
"Codex wants to run ".bold(),
cmd_span,
]),
@@ -245,7 +245,7 @@ impl UserApprovalWidget<'_> {
match decision {
ReviewDecision::Approved => {
lines.push(Line::from(vec![
"✔ ".fg(Color::Green),
"✔ ".fg(crate::colors::success()),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -256,7 +256,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::ApprovedForSession => {
lines.push(Line::from(vec![
"✔ ".fg(Color::Green),
"✔ ".fg(crate::colors::success()),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -267,7 +267,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::Denied => {
lines.push(Line::from(vec![
"✗ ".fg(Color::Red),
"✗ ".fg(crate::colors::error()),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
@@ -276,7 +276,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::Abort => {
lines.push(Line::from(vec![
"✗ ".fg(Color::Red),
"✗ ".fg(crate::colors::error()),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
@@ -340,7 +340,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
let style = if idx == self.selected_option {
Style::new()
.bg(crate::colors::light_blue())
.fg(Color::Black)
.fg(crate::colors::background())
} else {
Style::new().add_modifier(Modifier::DIM)
};