feat(browser,tui): CDP console logs; collapsible HUD; page scroll

browser/manager: install JS console hook on connect to capture logs immediately for already-loaded pages; prefer CDP-captured buffer and fall back to JS hook.\n\nbrowser/page: enable CDP Runtime/Log and buffer console + log events; expose tail accessor.\n\nTUI/chatwidget: add HUD expand/collapse toggles (Ctrl+B for Browser, Ctrl+P for Agents), PageUp/PageDown to page transcript, and track external Chrome connection status for titles.\n\nTUI/height_manager: allow caller-provided HUD target height to support stacked/collapsed layout; keep legacy aspect estimate as fallback.\n\napp, app_event_sender, tui: wire height target and redraws.\n\nRationale: ensure reliable console capture regardless of page state and improve TUI ergonomics for HUD and scrolling.
This commit is contained in:
James Peter
2025-09-01 09:53:22 +10:00
parent 3c8524d602
commit 34f68b00a5
7 changed files with 655 additions and 113 deletions
+66 -14
View File
@@ -942,6 +942,62 @@ impl BrowserManager {
// Continue even if cursor injection fails
}
// Ensure console capture is installed immediately for the current document.
// Without this, connecting to an already-loaded tab would only register
// the bootstrap for future documents, and an initial Browser Console read
// would return no logs. This eagerly hooks console methods now.
let console_hook = r#"(function(){
try {
if (!window.__codex_console_logs) {
window.__codex_console_logs = [];
const push = (level, message) => {
try {
window.__codex_console_logs.push({ timestamp: new Date().toISOString(), level, message });
if (window.__codex_console_logs.length > 2000) window.__codex_console_logs.shift();
} catch (_) {}
};
['log','warn','error','info','debug'].forEach(function(method) {
try {
const orig = console[method];
console[method] = function() {
try {
var args = Array.prototype.slice.call(arguments);
var msg = args.map(function(a) {
try {
if (a && typeof a === 'object') return JSON.stringify(a);
return String(a);
} catch (_) { return String(a); }
}).join(' ');
push(method, msg);
} catch(_) {}
if (orig) return orig.apply(console, arguments);
};
} catch(_) {}
});
window.addEventListener('error', function(e) {
try {
var msg = e && e.message ? e.message : 'Script error';
var stack = e && e.error && e.error.stack ? ('\n' + e.error.stack) : '';
push('exception', msg + stack);
} catch(_) {}
});
window.addEventListener('unhandledrejection', function(e) {
try {
var reason = e && e.reason;
if (reason && typeof reason === 'object') { try { reason = JSON.stringify(reason); } catch(_) {} }
push('unhandledrejection', String(reason));
} catch(_) {}
});
}
return true;
} catch (_) { return false; }
})()"#;
if let Err(e) = page.inject_js(console_hook).await {
warn!("Failed to install console capture on page creation: {}", e);
}
// Start navigation monitoring for this page
self.start_navigation_monitor(Arc::clone(&page)).await;
// Start viewport monitor (low-frequency, non-invasive)
@@ -1492,6 +1548,13 @@ impl BrowserManager {
pub async fn get_console_logs(&self, lines: Option<usize>) -> Result<serde_json::Value> {
let page = self.get_or_create_page().await?;
// 1) Prefer CDP-captured buffer (event-based). If we have entries, return them.
let cdp_logs = page.get_console_logs_tail(lines).await;
if cdp_logs.as_array().map(|a| !a.is_empty()).unwrap_or(false) {
return Ok(cdp_logs);
}
// 2) Fallback to JS-installed hook (ensures capture on pages where events are unavailable).
let requested = lines.unwrap_or(0);
let script = format!(
r#"(function() {{
@@ -1505,7 +1568,6 @@ impl BrowserManager {
}} catch (_) {{}}
}};
// Override console methods once
['log','warn','error','info','debug'].forEach(function(method) {{
try {{
const orig = console[method];
@@ -1513,10 +1575,8 @@ impl BrowserManager {
try {{
var args = Array.prototype.slice.call(arguments);
var msg = args.map(function(a) {{
try {{
if (a && typeof a === 'object') return JSON.stringify(a);
return String(a);
}} catch (_) {{ return String(a); }}
try {{ if (a && typeof a === 'object') return JSON.stringify(a); return String(a); }}
catch (_) {{ return String(a); }}
}}).join(' ');
push(method, msg);
}} catch(_) {{}}
@@ -1525,7 +1585,6 @@ impl BrowserManager {
}} catch(_) {{}}
}});
// Capture uncaught errors
window.addEventListener('error', function(e) {{
try {{
var msg = e && e.message ? e.message : 'Script error';
@@ -1533,13 +1592,10 @@ impl BrowserManager {
push('exception', msg + stack);
}} catch(_) {{}}
}});
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', function(e) {{
try {{
var reason = e && e.reason;
if (reason && typeof reason === 'object') {{
try {{ reason = JSON.stringify(reason); }} catch(_) {{}}
}}
if (reason && typeof reason === 'object') {{ try {{ reason = JSON.stringify(reason); }} catch(_) {{}} }}
push('unhandledrejection', String(reason));
}} catch(_) {{}}
}});
@@ -1554,10 +1610,6 @@ impl BrowserManager {
}})()"#
);
// Use raw JS injection so our console hooks persist beyond the
// temporary wrapper used by execute_javascript(). This ensures
// console.* overrides remain active and logs accumulate across
// page interactions until explicitly cleared.
page.inject_js(&script).await
}
+83
View File
@@ -17,6 +17,9 @@ use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocument
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams;
use chromiumoxide::page::Page as CdpPage;
use chromiumoxide::cdp::js_protocol::runtime as cdp_runtime;
use chromiumoxide::cdp::browser_protocol::log as cdp_log;
use futures::StreamExt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
@@ -46,6 +49,8 @@ pub struct Page {
current_url: Arc<RwLock<Option<String>>>,
// Add cursor state tracking (New)
cursor_state: Arc<Mutex<CursorState>>,
// Buffer for CDP-captured console logs
console_logs: Arc<Mutex<Vec<serde_json::Value>>>,
// Screenshot path preflight cache:
// - We strongly prefer compositor captures via from_surface(false) to avoid visible flashes in the
// user's real Chrome window. However, that path can be flaky or unavailable when the window is not
@@ -73,6 +78,7 @@ impl Page {
current_url: Arc::new(RwLock::new(None)),
cursor_state: Arc::new(Mutex::new(initial_cursor)),
preflight_cache: Arc::new(Mutex::new(None)),
console_logs: Arc::new(Mutex::new(Vec::new())),
};
// Register a unified bootstrap (runs on every new document):
@@ -88,6 +94,68 @@ impl Page {
}
});
// Enable CDP Runtime/Log and start capturing console events into an internal buffer.
// This complements the JS hook and works even if the page overwrites console later.
let cdp_page_events = page.cdp_page.clone();
let logs_buf = page.console_logs.clone();
tokio::spawn(async move {
// Best-effort enable; ignore failures silently to avoid breaking page creation.
let _ = cdp_page_events.execute(cdp_runtime::EnableParams::default()).await;
let _ = cdp_page_events.execute(cdp_log::EnableParams::default()).await;
// Listen for Runtime.consoleAPICalled
if let Ok(mut stream) = cdp_page_events
.event_listener::<cdp_runtime::EventConsoleApiCalled>()
.await
{
while let Some(evt) = stream.next().await {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i128)
.unwrap_or(0);
// Join args into a readable string; also keep raw values
let text = match serde_json::to_string(&evt.args) {
Ok(s) => s,
Err(_) => String::new(),
};
let item = serde_json::json!({
"ts_unix_ms": ts,
"level": format!("{:?}", evt.r#type),
"message": text,
"source": "cdp:runtime"
});
let mut buf = logs_buf.lock().await;
buf.push(item);
if buf.len() > 2000 { buf.remove(0); }
}
}
// Also listen for Log.entryAdded (browser-side logs)
if let Ok(mut stream) = cdp_page_events
.event_listener::<cdp_log::EventEntryAdded>()
.await
{
while let Some(evt) = stream.next().await {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i128)
.unwrap_or(0);
let entry = &evt.entry;
let item = serde_json::json!({
"ts_unix_ms": ts,
"level": format!("{:?}", entry.level),
"message": entry.text,
"source": "cdp:log",
"url": entry.url,
"line": entry.line_number
});
let mut buf = logs_buf.lock().await;
buf.push(item);
if buf.len() > 2000 { buf.remove(0); }
}
}
});
page
}
@@ -1295,6 +1363,21 @@ impl Page {
Ok(())
}
/// Return a snapshot (tail) of the CDP-captured console buffer.
pub async fn get_console_logs_tail(&self, lines: Option<usize>) -> serde_json::Value {
let buf = self.console_logs.lock().await;
if buf.is_empty() {
return serde_json::Value::Array(vec![]);
}
let n = lines.unwrap_or(0);
let slice: Vec<serde_json::Value> = if n > 0 && n < buf.len() {
buf[buf.len() - n..].to_vec()
} else {
buf.clone()
};
serde_json::Value::Array(slice)
}
pub async fn get_url(&self) -> Result<String> {
let url_guard = self.current_url.read().await;
url_guard.clone().ok_or(BrowserError::PageNotLoaded)
+40 -22
View File
@@ -59,7 +59,9 @@ enum AppState<'a> {
pub(crate) struct App<'a> {
_server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
app_event_rx: Receiver<AppEvent>,
// Split event receivers: high‑priority (input) and bulk (streaming)
app_event_rx_high: Receiver<AppEvent>,
app_event_rx_bulk: Receiver<AppEvent>,
app_state: AppState<'a>,
/// Config is stored here so we can recreate ChatWidgets as needed.
@@ -120,8 +122,10 @@ impl App<'_> {
AuthMode::ApiKey,
)));
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
// Split queues so interactive input never waits behind bulk updates.
let (high_tx, app_event_rx_high) = channel();
let (bulk_tx, app_event_rx_bulk) = channel();
let app_event_tx = AppEventSender::new_dual(high_tx.clone(), bulk_tx.clone());
let pending_redraw = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -223,7 +227,8 @@ impl App<'_> {
Self {
_server: conversation_manager,
app_event_tx,
app_event_rx,
app_event_rx_high,
app_event_rx_bulk,
app_state,
config,
file_search,
@@ -278,10 +283,15 @@ impl App<'_> {
let app_event_tx = self.app_event_tx.clone();
app_event_tx.send(AppEvent::RequestRedraw);
while let Ok(event) = self.app_event_rx.recv() {
'main: loop {
let event = match self.next_event_priority() { Some(e) => e, None => break 'main };
match event {
AppEvent::InsertHistory(lines) => match &mut self.app_state {
AppEvent::InsertHistory(mut lines) => match &mut self.app_state {
AppState::Chat { widget } => {
// Coalesce consecutive InsertHistory events to reduce redraw churn.
while let Ok(AppEvent::InsertHistory(mut more)) = self.app_event_rx_bulk.try_recv() {
lines.append(&mut more);
}
tracing::debug!("app: InsertHistory lines={}", lines.len());
widget.insert_history_lines(lines)
},
@@ -327,6 +337,7 @@ impl App<'_> {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if self.pending_redraw.load(Ordering::Relaxed) { continue; }
// Advance streaming animation: commit at most one queued line
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
@@ -427,12 +438,8 @@ impl App<'_> {
kind: KeyEventKind::Press,
..
} => match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::Chat { widget } => { widget.on_ctrl_c(); }
AppState::Onboarding { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); }
},
KeyEvent {
code: KeyCode::Char('z'),
@@ -498,9 +505,7 @@ impl App<'_> {
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);
}
AppEvent::ExitRequest => {
break;
}
AppEvent::ExitRequest => { break 'main; }
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
@@ -556,14 +561,10 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => {
break;
}
SlashCommand::Quit => { break 'main; }
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(&self.config.codex_home) {
tracing::error!("failed to logout: {e}");
}
break;
if let Err(e) = codex_login::logout(&self.config.codex_home) { tracing::error!("failed to logout: {e}"); }
break 'main;
}
SlashCommand::Diff => {
let tx = self.app_event_tx.clone();
@@ -921,6 +922,23 @@ impl App<'_> {
Ok(())
}
/// Pull the next event with priority for interactive input.
/// Never returns None due to idleness; only returns None if both channels disconnect.
fn next_event_priority(&self) -> Option<AppEvent> {
use std::sync::mpsc::RecvTimeoutError::{Timeout, Disconnected};
loop {
if let Ok(ev) = self.app_event_rx_high.try_recv() { return Some(ev); }
if let Ok(ev) = self.app_event_rx_bulk.try_recv() { return Some(ev); }
match self.app_event_rx_high.recv_timeout(Duration::from_millis(20)) {
Ok(ev) => return Some(ev),
Err(Timeout) => continue,
Err(Disconnected) => break,
}
}
// High channel disconnected; try blocking on bulk as a last resort
self.app_event_rx_bulk.recv().ok()
}
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
+27 -4
View File
@@ -5,13 +5,24 @@ use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
pub app_event_tx: Sender<AppEvent>,
// High‑priority events (input, resize, redraw scheduling) are routed here.
high_tx: Sender<AppEvent>,
// Bulk/streaming events (history inserts, commit ticks, file search, etc.).
bulk_tx: Sender<AppEvent>,
}
impl AppEventSender {
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
Self { app_event_tx }
/// Create a sender that splits events by priority across two channels.
pub(crate) fn new_dual(high_tx: Sender<AppEvent>, bulk_tx: Sender<AppEvent>) -> Self {
Self { high_tx, bulk_tx }
}
/// Backward‑compatible constructor for tests/fixtures that expect a single
/// channel. Routes both high‑priority and bulk events to the same sender.
#[allow(dead_code)]
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
Self { high_tx: app_event_tx.clone(), bulk_tx: app_event_tx }
}
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
@@ -21,7 +32,19 @@ impl AppEventSender {
if !matches!(event, AppEvent::CodexOp(_)) {
session_log::log_inbound_app_event(&event);
}
if let Err(e) = self.app_event_tx.send(event) {
let is_high = matches!(
event,
AppEvent::KeyEvent(_)
| AppEvent::MouseEvent(_)
| AppEvent::Paste(_)
| AppEvent::RequestRedraw
| AppEvent::Redraw
| AppEvent::ScheduleFrameIn(_)
| AppEvent::ExitRequest
);
let tx = if is_high { &self.high_tx } else { &self.bulk_tx };
if let Err(e) = tx.send(event) {
tracing::error!("failed to send event: {e}");
}
}
+420 -57
View File
@@ -218,6 +218,13 @@ pub(crate) struct ChatWidget<'a> {
// Track prior HUD visibility to emit toggle events to HeightManager
last_hud_present: std::cell::Cell<bool>,
// HUD panel expansion state (stacked layout): only one expands at a time
browser_hud_expanded: bool,
agents_hud_expanded: bool,
// True when connected to external Chrome via CDP; affects HUD titles
browser_is_external: bool,
// Prefix sums of content heights (including spacing) for fast scroll range
prefix_sums: std::cell::RefCell<Vec<u16>>,
// Cache key for prefix_sums to avoid rebuilding on pure scroll frames
@@ -1546,7 +1553,32 @@ impl ChatWidget<'_> {
self.last_hud_present.set(hud_present);
}
hm.begin_frame(area, hud_present, bottom_desired, font_cell)
// Compute a target HUD height for stacked layout with collapsible headers.
// - Collapsed header uses 1 content line + borders = 3 rows
// - When both are collapsed: 2 headers (Agents above Browser) => 6 rows
// - When one is expanded: expanded gets remaining height budget above/below the other header
let collapsed_unit: u16 = 3;
let present_count: u16 = (has_active_agents as u16) + (has_browser_screenshot as u16);
// Estimate expanded content preferred height using a 16:9 aspect based on full width
let (cw, ch) = font_cell;
let inner_cols = area.width.saturating_sub(4); // account for padding + borders
let number = (inner_cols as u32) * 3 * (cw as u32);
let denom = 4 * (ch as u32);
let preferred_expanded: u16 = ((number / denom) as u16).saturating_add(2); // include borders
let hud_target: Option<u16> = if !hud_present || present_count == 0 {
None
} else {
let base_collapsed = collapsed_unit
* ((has_active_agents as u16) + (has_browser_screenshot as u16))
.max(1);
let any_expanded = self.browser_hud_expanded || self.agents_hud_expanded;
let target = if any_expanded { base_collapsed.saturating_add(preferred_expanded) } else { base_collapsed };
Some(target)
};
hm.begin_frame(area, hud_present, bottom_desired, font_cell, hud_target)
}
fn finalize_active_stream(&mut self) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
@@ -1692,6 +1724,8 @@ impl ChatWidget<'_> {
crate::height_manager::HeightManagerConfig::default(),
)),
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
prefix_sums: std::cell::RefCell::new(Vec::new()),
last_prefix_width: std::cell::Cell::new(0),
last_prefix_count: std::cell::Cell::new(0),
@@ -1704,6 +1738,7 @@ impl ChatWidget<'_> {
session_id: None,
pending_jump_back: None,
active_task_ids: HashSet::new(),
browser_is_external: false,
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
@@ -1806,6 +1841,8 @@ impl ChatWidget<'_> {
crate::height_manager::HeightManagerConfig::default(),
)),
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
prefix_sums: std::cell::RefCell::new(Vec::new()),
last_prefix_width: std::cell::Cell::new(0),
last_prefix_count: std::cell::Cell::new(0),
@@ -1818,6 +1855,7 @@ impl ChatWidget<'_> {
session_id: None,
pending_jump_back: None,
active_task_ids: HashSet::new(),
browser_is_external: false,
}
}
@@ -2031,6 +2069,69 @@ impl ChatWidget<'_> {
self.bottom_pane.clear_ctrl_c_quit_hint();
}
// Global HUD toggles (avoid conflicting with common editor keys):
// - Ctrl+B: toggle Browser panel (expand/collapse)
// - Ctrl+G: toggle Agents panel (expand/collapse)
if let KeyEvent {
code: crossterm::event::KeyCode::Char('b'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
self.toggle_browser_hud();
return;
}
if let KeyEvent {
code: crossterm::event::KeyCode::Char('p'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
self.toggle_agents_hud();
return;
}
// Fast-path PageUp/PageDown to scroll the transcript by a viewport at a time.
if let crossterm::event::KeyEvent {
code: crossterm::event::KeyCode::PageUp,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
let step = self.last_history_viewport_height.get().max(1);
let new_offset = self
.scroll_offset
.saturating_add(step)
.min(self.last_max_scroll.get());
self.scroll_offset = new_offset;
self.bottom_pane.set_compact_compose(true);
self.flash_scrollbar();
self.app_event_tx.send(AppEvent::RequestRedraw);
self.height_manager.borrow_mut().record_event(HeightEvent::UserScroll);
return;
}
if let crossterm::event::KeyEvent {
code: crossterm::event::KeyCode::PageDown,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
let step = self.last_history_viewport_height.get().max(1);
if self.scroll_offset > step {
self.scroll_offset = self.scroll_offset.saturating_sub(step);
} else {
self.scroll_offset = 0;
// At bottom: restore normal composer spacing
self.bottom_pane.set_compact_compose(false);
}
self.flash_scrollbar();
self.app_event_tx.send(AppEvent::RequestRedraw);
self.height_manager.borrow_mut().record_event(HeightEvent::UserScroll);
return;
}
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
// Commit pending jump-back (make trimming permanent) before submission
@@ -2116,6 +2217,22 @@ impl ChatWidget<'_> {
}
}
fn toggle_browser_hud(&mut self) {
let new_state = !self.browser_hud_expanded;
self.browser_hud_expanded = new_state;
if new_state { self.agents_hud_expanded = false; }
self.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
self.request_redraw();
}
fn toggle_agents_hud(&mut self) {
let new_state = !self.agents_hud_expanded;
self.agents_hud_expanded = new_state;
if new_state { self.browser_hud_expanded = false; }
self.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
self.request_redraw();
}
// dispatch_command() removed — command routing is handled at the App layer via AppEvent::DispatchCommand
pub(crate) fn handle_paste(&mut self, text: String) {
@@ -5313,6 +5430,9 @@ impl ChatWidget<'_> {
url.clone()
};
// We are navigating with the internal browser
self.browser_is_external = false;
// Navigate to URL and wait for it to load
let latest_screenshot = self.latest_browser_screenshot.clone();
let app_event_tx = self.app_event_tx.clone();
@@ -5660,6 +5780,7 @@ impl ChatWidget<'_> {
#[allow(dead_code)]
fn switch_to_internal_browser(&mut self) {
// Switch to internal browser mode
self.browser_is_external = false;
let latest_screenshot = self.latest_browser_screenshot.clone();
let app_event_tx = self.app_event_tx.clone();
@@ -5744,6 +5865,7 @@ impl ChatWidget<'_> {
fn handle_chrome_connection(&mut self, port: Option<u16>) {
tracing::info!("[cdp] handle_chrome_connection begin, port={:?}", port);
self.browser_is_external = true;
let latest_screenshot = self.latest_browser_screenshot.clone();
let app_event_tx = self.app_event_tx.clone();
let port_display = port.map_or("auto-detect".to_string(), |p| p.to_string());
@@ -5812,6 +5934,10 @@ impl ChatWidget<'_> {
if !handled_disconnect {
// Switch to external Chrome mode with default/auto-detected port
self.handle_chrome_connection(None);
} else {
// We just disconnected; reflect in title immediately
self.browser_is_external = false;
self.request_redraw();
}
return;
}
@@ -6145,7 +6271,7 @@ impl ChatWidget<'_> {
let picker = cached_picker.as_ref().unwrap();
// quantize step by protocol to avoid rounding bias
let (qx, qy): (u16, u16) = match picker.protocol_type() {
let (_qx, _qy): (u16, u16) = match picker.protocol_type() {
ProtocolType::Halfblocks => (1, 2), // half-block cell = 1 col x 2 half-rows
_ => (1, 1), // pixel protocols (Kitty/iTerm2/Sixel)
};
@@ -6167,39 +6293,38 @@ impl ChatWidget<'_> {
cols_by_h = 1;
}
let (used_cols, used_rows) = if rows_by_w <= rows {
let (_used_cols, _used_rows) = if rows_by_w <= rows {
(cols, rows_by_w)
} else {
(cols_by_h, rows)
};
// quantize to protocol grid
let mut used_cols_u16 = used_cols as u16;
let mut used_rows_u16 = used_rows as u16;
if qx > 1 {
let rem = used_cols_u16 % qx;
if rem != 0 {
used_cols_u16 = used_cols_u16.saturating_sub(rem).max(qx);
}
// Compute a centered target rect based on image aspect and font cell size
let (cell_w, cell_h) = self.measured_font_size();
let area_px_w = (area.width as u32) * (cell_w as u32);
let area_px_h = (area.height as u32) * (cell_h as u32);
// If either dimension is zero, bail to placeholder
if area.width == 0 || area.height == 0 || area_px_w == 0 || area_px_h == 0 {
self.render_screenshot_placeholder(path, area, buf);
return;
}
if qy > 1 {
let rem = used_rows_u16 % qy;
if rem != 0 {
used_rows_u16 = used_rows_u16.saturating_sub(rem).max(qy);
}
}
used_cols_u16 = used_cols_u16.min(area.width).max(1);
used_rows_u16 = used_rows_u16.min(area.height).max(1);
// center both axes
let hpad = (area.width.saturating_sub(used_cols_u16)) / 2;
let vpad = (area.height.saturating_sub(used_rows_u16)) / 2;
let target = Rect {
x: area.x + hpad,
y: area.y + vpad,
width: used_cols_u16,
height: used_rows_u16,
let (img_w, img_h) = match image::image_dimensions(path) {
Ok(dim) => dim,
Err(_) => { self.render_screenshot_placeholder(path, area, buf); return; }
};
let scale_num_w = area_px_w;
let scale_num_h = area_px_h;
let scale_w = scale_num_w as f64 / img_w as f64;
let scale_h = scale_num_h as f64 / img_h as f64;
let scale = scale_w.min(scale_h).max(0.0);
// Compute target size in cells
let target_w_cells = ((img_w as f64 * scale) / (cell_w as f64)).floor() as u16;
let target_h_cells = ((img_h as f64 * scale) / (cell_h as f64)).floor() as u16;
let target_w = target_w_cells.clamp(1, area.width);
let target_h = target_h_cells.clamp(1, area.height);
let target_x = area.x + (area.width.saturating_sub(target_w)) / 2;
let target_y = area.y + (area.height.saturating_sub(target_h)) / 2;
let target = Rect { x: target_x, y: target_y, width: target_w, height: target_h };
// cache by (path, target)
let needs_recreate = {
@@ -6278,7 +6403,7 @@ impl ChatWidget<'_> {
}
impl ChatWidget<'_> {
/// Render the combined HUD with browser and/or agent panels based on what's active
/// Render the combined HUD with browser and/or agent panels (stacked full-width)
fn render_hud(&self, area: Rect, buf: &mut Buffer) {
// Check what's active
let has_browser_screenshot = self
@@ -6297,31 +6422,65 @@ impl ChatWidget<'_> {
height: area.height,
};
// Determine layout based on what's active
if has_browser_screenshot && has_active_agents {
// Both panels: 50/50 split
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(padded_area);
// Determine layout based on what's active (stacked, full width)
let header_h: u16 = 3;
match (has_active_agents, has_browser_screenshot) {
(true, true) => {
let max_expanded = ((padded_area.height as u32) * 30 / 100) as u16; // 30%
let (top_h, bottom_h) = if self.agents_hud_expanded && !self.browser_hud_expanded {
(padded_area.height.saturating_sub(header_h).min(max_expanded.max(header_h + 2)), header_h)
} else if self.browser_hud_expanded && !self.agents_hud_expanded {
(header_h, padded_area.height.saturating_sub(header_h).min(max_expanded.max(header_h + 2)))
} else {
let top = header_h.min(padded_area.height);
let bottom = padded_area.height.saturating_sub(top).min(header_h);
(top, bottom)
};
let chunks = Layout::vertical([
Constraint::Length(top_h),
Constraint::Length(bottom_h),
]).areas::<2>(padded_area);
self.render_browser_panel(chunks[0], buf);
self.render_agent_panel(chunks[1], buf);
} else if has_browser_screenshot {
// Only browser: 50% width on the left side
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(padded_area);
self.render_browser_panel(chunks[0], buf);
// Right side remains empty
} else if has_active_agents {
// Only agents: 50% width on the left side
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(padded_area);
self.render_agent_panel(chunks[0], buf);
// Right side remains empty
// Agents on top
if self.agents_hud_expanded {
self.render_agent_panel(chunks[0], buf);
} else {
self.render_agents_header(chunks[0], buf);
}
// Browser on bottom
if self.browser_hud_expanded {
self.render_browser_panel(chunks[1], buf);
} else {
self.render_browser_header(chunks[1], buf);
}
}
(true, false) => {
if self.agents_hud_expanded {
let max_expanded = ((padded_area.height as u32) * 30 / 100) as u16;
let h = padded_area.height.min(max_expanded.max(header_h + 2));
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_agent_panel(a, buf);
}
else {
let h = header_h.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_agents_header(a, buf);
}
}
(false, true) => {
if self.browser_hud_expanded {
let max_expanded = ((padded_area.height as u32) * 30 / 100) as u16;
let h = padded_area.height.min(max_expanded.max(header_h + 2));
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_browser_panel(a, buf);
}
else {
let h = header_h.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_browser_header(a, buf);
}
}
(false, false) => {}
}
}
@@ -6333,21 +6492,185 @@ impl ChatWidget<'_> {
if let Ok(screenshot_lock) = self.latest_browser_screenshot.lock() {
if let Some((screenshot_path, url)) = &*screenshot_lock {
use ratatui::layout::Margin;
use ratatui::text::{Line as RLine, Span};
// Use the full area for the browser preview
let screenshot_block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", url))
.title(format!(" {} ", self.browser_title()))
.border_style(Style::default().fg(crate::colors::border()));
let inner_screenshot = screenshot_block.inner(area);
let inner = screenshot_block.inner(area);
screenshot_block.render(area, buf);
// Render the screenshot using the full inner area
self.render_screenshot_highlevel(screenshot_path, inner_screenshot, buf);
// Render a one-line collapsed header inside (with padding), right hint = Collapse
let line_area = inner.inner(Margin::new(1, 0));
let header_line = Rect { x: line_area.x, y: line_area.y, width: line_area.width, height: 1 };
let key_hint_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().dim();
let is_active = true;
let dot_style = if is_active { Style::default().fg(crate::colors::success_green()) } else { Style::default().fg(crate::colors::text_dim()) };
let mut left_spans: Vec<Span> = Vec::new();
left_spans.push(Span::styled("•", dot_style));
// no status text; dot conveys status
// Spaces between status and URL; no label
left_spans.push(Span::raw(" "));
left_spans.push(Span::raw(url.clone()));
let right_spans: Vec<Span> = vec![
Span::styled("Collapse: ", label_style),
Span::from("Ctrl+B").style(key_hint_style),
];
let measure = |spans: &Vec<Span>| -> usize { spans.iter().map(|s| s.content.chars().count()).sum() };
let left_len = measure(&left_spans);
let right_len = measure(&right_spans);
let total_width = line_area.width as usize;
if total_width > left_len + right_len {
let spacer = " ".repeat(total_width - left_len - right_len);
left_spans.push(Span::from(spacer));
}
let mut spans = left_spans;
spans.extend(right_spans);
Paragraph::new(RLine::from(spans)).render(header_line, buf);
// Leave one blank spacer line, then render the screenshot
let body = Rect { x: inner.x, y: inner.y + 2, width: inner.width, height: inner.height.saturating_sub(2) };
self.render_screenshot_highlevel(screenshot_path, body, buf);
}
}
}
/// Render a collapsed header for the browser HUD with status (1 line + border)
fn render_browser_header(&self, area: Rect, buf: &mut Buffer) {
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::text::{Line as RLine, Span};
use ratatui::layout::Margin;
let (url_opt, status_str) = {
let url = self
.latest_browser_screenshot
.lock()
.ok()
.and_then(|g| g.as_ref().map(|(_, u)| u.clone()));
let status = self.get_browser_status_string();
(url, status)
};
let title = format!(" {} ", self.browser_title());
let is_active = url_opt.is_some();
let summary = match url_opt {
Some(u) if !u.is_empty() => format!("{}", u),
_ => status_str,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.title(title);
let inner = block.inner(area);
block.render(area, buf);
let content = inner.inner(Margin::new(1, 0)); // 1 space padding inside border
let key_hint_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().dim(); // match top status bar label
// Left side: status dot + text (no label) and URL
let mut left_spans: Vec<Span> = Vec::new();
let dot_style = if is_active { Style::default().fg(crate::colors::success_green()) } else { Style::default().fg(crate::colors::text_dim()) };
left_spans.push(Span::styled("•", dot_style));
// Choose status text: Active if we have a URL/screenshot, else Idle
// no status text; dot conveys status
// Spaces between status and URL; no label
left_spans.push(Span::raw(" "));
left_spans.push(Span::raw(summary));
// Right side: Expand hint
let right_spans: Vec<Span> = vec![
Span::styled("Expand: ", label_style),
Span::from("Ctrl+B").style(key_hint_style),
];
let measure = |spans: &Vec<Span>| -> usize { spans.iter().map(|s| s.content.chars().count()).sum() };
let left_len = measure(&left_spans);
let right_len = measure(&right_spans);
let total_width = content.width as usize;
let trailing_pad = 0usize; // Paragraph will draw to edge; we already padded left/right
if total_width > left_len + right_len + trailing_pad {
let spacer = " ".repeat(total_width - left_len - right_len - trailing_pad);
left_spans.push(Span::from(spacer));
}
let mut spans = left_spans;
spans.extend(right_spans);
Paragraph::new(RLine::from(spans)).render(content, buf);
}
/// Render a collapsed header for the agents HUD with counts/list (1 line + border)
fn render_agents_header(&self, area: Rect, buf: &mut Buffer) {
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::text::{Line as RLine, Span};
use ratatui::layout::Margin;
let count = self.active_agents.len();
let summary = if count == 0 && self.agents_ready_to_start {
"preparing context".to_string()
} else if count == 0 {
"no active agents".to_string()
} else {
let mut parts: Vec<String> = Vec::new();
for a in self.active_agents.iter().take(3) {
let s = match a.status { AgentStatus::Pending => "pending", AgentStatus::Running => "running", AgentStatus::Completed => "done", AgentStatus::Failed => "failed" };
parts.push(format!("{} ({})", a.name, s));
}
let extra = if count > 3 { format!(" +{}", count - 3) } else { String::new() };
format!("{}{}", parts.join(", "), extra)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.title(" Agents ");
let inner = block.inner(area);
block.render(area, buf);
let content = inner.inner(Margin::new(1, 0)); // 1 space padding inside border
let key_hint_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().dim(); // match top status bar label
// Left side: status dot + text (no label) and Agents summary
let mut left_spans: Vec<Span> = Vec::new();
let is_active = !self.active_agents.is_empty() || self.agents_ready_to_start;
let dot_style = if is_active { Style::default().fg(crate::colors::success_green()) } else { Style::default().fg(crate::colors::text_dim()) };
left_spans.push(Span::styled("•", dot_style));
// no status text; dot conveys status
left_spans.push(Span::styled(" · ", Style::default().fg(crate::colors::text())));
// Spaces between status and summary; no label
left_spans.push(Span::raw(" "));
left_spans.push(Span::raw(summary));
// Right side: Expand hint
let right_spans: Vec<Span> = vec![
Span::styled("Expand: ", label_style),
Span::from("Ctrl+P").style(key_hint_style),
];
let measure = |spans: &Vec<Span>| -> usize { spans.iter().map(|s| s.content.chars().count()).sum() };
let left_len = measure(&left_spans);
let right_len = measure(&right_spans);
let total_width = content.width as usize;
let trailing_pad = 0usize;
if total_width > left_len + right_len + trailing_pad {
let spacer = " ".repeat(total_width - left_len - right_len - trailing_pad);
left_spans.push(Span::from(spacer));
}
let mut spans = left_spans;
spans.extend(right_spans);
Paragraph::new(RLine::from(spans)).render(content, buf);
}
fn get_browser_status_string(&self) -> String { "Browser".to_string() }
fn browser_title(&self) -> &'static str {
if self.browser_is_external { "Chrome" } else { "Browser" }
}
/// Render the agent status panel in the HUD
fn render_agent_panel(&self, area: Rect, buf: &mut Buffer) {
use ratatui::text::Line as RLine;
@@ -6374,6 +6697,46 @@ impl ChatWidget<'_> {
let inner_agent = agent_block.inner(area);
agent_block.render(area, buf);
// Render a one-line collapsed header inside expanded panel
use ratatui::layout::Margin;
let header_pad = inner_agent.inner(Margin::new(1, 0));
let header_line = Rect { x: header_pad.x, y: header_pad.y, width: header_pad.width, height: 1 };
let key_hint_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().dim();
let is_active = !self.active_agents.is_empty() || self.agents_ready_to_start;
let dot_style = if is_active { Style::default().fg(crate::colors::success_green()) } else { Style::default().fg(crate::colors::text_dim()) };
// Build summary like collapsed header
let count = self.active_agents.len();
let summary = if count == 0 && self.agents_ready_to_start { "preparing context".to_string() } else if count == 0 { "no active agents".to_string() } else {
let mut parts: Vec<String> = Vec::new();
for a in self.active_agents.iter().take(3) {
let s = match a.status { AgentStatus::Pending => "pending", AgentStatus::Running => "running", AgentStatus::Completed => "done", AgentStatus::Failed => "failed" };
parts.push(format!("{} ({})", a.name, s));
}
let extra = if count > 3 { format!(" +{}", count - 3) } else { String::new() };
format!("{}{}", parts.join(", "), extra)
};
let mut left_spans: Vec<Span> = Vec::new();
left_spans.push(Span::styled("•", dot_style));
// no status text; dot conveys status
left_spans.push(Span::styled(" · ", Style::default().fg(crate::colors::text())));
// Spaces between status and summary; no label
left_spans.push(Span::raw(" "));
left_spans.push(Span::raw(summary));
let right_spans: Vec<Span> = vec![
Span::styled("Collapse: ", label_style),
Span::from("Ctrl+P").style(key_hint_style),
];
let measure = |spans: &Vec<Span>| -> usize { spans.iter().map(|s| s.content.chars().count()).sum() };
let left_len = measure(&left_spans);
let right_len = measure(&right_spans);
let total_width = header_line.width as usize;
if total_width > left_len + right_len { left_spans.push(Span::from(" ".repeat(total_width - left_len - right_len))); }
let mut spans = left_spans; spans.extend(right_spans);
Paragraph::new(RLine::from(spans)).render(header_line, buf);
// Body area excludes the header line and a spacer line
let inner_agent = Rect { x: inner_agent.x, y: inner_agent.y + 2, width: inner_agent.width, height: inner_agent.height.saturating_sub(2) };
// Dynamically calculate sparkline height based on agent activity
// More agents = taller sparkline area
+14 -13
View File
@@ -115,6 +115,9 @@ impl HeightManager {
hud_present: bool,
bottom_desired_height: u16,
font_cell: (u16, u16),
// Optional target height for HUD computed by caller (e.g., stacked/collapsed layout).
// When None, a default aspect-based calculation is used.
hud_target_override: Option<u16>,
) -> Vec<Rect> {
#[cfg(debug_assertions)]
{
@@ -174,19 +177,17 @@ impl HeightManager {
// Determine HUD height if present.
let mut hud_h: u16;
if hud_present {
// This mirrors the existing preview logic while centralizing its decision.
// Compute HUD target height using 16:9 aspect on inner width.
let padded_area = Rect { x: area.x + 1, y: area.y, width: area.width.saturating_sub(2), height: area.height };
let right = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Percentage(50),
]).split(padded_area)[1];
let inner_cols = right.width.saturating_sub(2);
let (cw, ch) = font_cell;
let number = (inner_cols as u32) * 3 * (cw as u32);
let denom = 4 * (ch as u32);
let mut target = ((number / denom) as u16).saturating_add(2); // add borders
target = target.saturating_sub(1); // tighten by one line as in current code
// Use caller-provided target when available; otherwise fall back to
// an aspect-based estimate similar to the older preview logic.
let mut target = if let Some(t) = hud_target_override { t } else {
// Compute HUD target height using 16:9 aspect on full inner width.
let padded_area = Rect { x: area.x + 1, y: area.y, width: area.width.saturating_sub(2), height: area.height };
let inner_cols = padded_area.width.saturating_sub(2);
let (cw, ch) = font_cell;
let number = (inner_cols as u32) * 3 * (cw as u32);
let denom = 4 * (ch as u32);
((number / denom) as u16).saturating_add(1) // include borders budget
};
// Keep within budget: reserve space for status + bottom + >=1 row history.
let vertical_budget = area
+5 -3
View File
@@ -14,7 +14,7 @@ use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::style::SetColors;
use crossterm::style::{Color as CtColor, SetBackgroundColor, SetForegroundColor, ResetColor};
use crossterm::style::{Color as CtColor, SetBackgroundColor, SetForegroundColor};
use crossterm::style::Print;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
@@ -110,8 +110,10 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
for y in 0..rows {
execute!(stdout(), MoveTo(0, y), Print(&blank))?;
}
// Restore cursor to home and leave colors configured for subsequent drawing.
execute!(stdout(), MoveTo(0, 0), ResetColor, SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())))?;
// Restore cursor to home and keep our colors configured for subsequent drawing.
// Avoid ResetColor here to prevent some terminals from flashing to their
// profile default background (e.g., white) between frames.
execute!(stdout(), MoveTo(0, 0), SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())))?;
}
// Wrap stdout in a BufWriter to reduce syscalls during rendering.