mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user