From 8f66420e8819d955b446830b5e1a081d6ff99d64 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 7 May 2026 13:15:56 +1000 Subject: [PATCH 1/3] feat: improve responsive terminal UI --- .gitignore | 3 +- scripts/install-abtop.sh | 33 ++ src/app.rs | 208 +++++++++ src/main.rs | 67 ++- src/ui/context.rs | 15 +- src/ui/footer.rs | 122 +++--- src/ui/header.rs | 6 +- src/ui/mcp.rs | 17 +- src/ui/mod.rs | 890 ++++++++++++++++++++++++++++++++++++--- src/ui/ports.rs | 14 +- src/ui/projects.rs | 14 +- src/ui/quota.rs | 14 +- src/ui/sessions.rs | 255 ++++++----- src/ui/tokens.rs | 16 +- 14 files changed, 1452 insertions(+), 222 deletions(-) create mode 100755 scripts/install-abtop.sh diff --git a/.gitignore b/.gitignore index efcd773..026a6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .agent/ .claude/worktrees/ demo.tape -.idea/ \ No newline at end of file +.idea/ +.perles/ \ No newline at end of file diff --git a/scripts/install-abtop.sh b/scripts/install-abtop.sh new file mode 100755 index 0000000..065772c --- /dev/null +++ b/scripts/install-abtop.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" +install_dir="${INSTALL_DIR:-$HOME/bin}" +install_path="${ABTOP_INSTALL_PATH:-$install_dir/abtop}" +target_dir="$repo_root/target" +built_binary="$target_dir/release/abtop" + +if ! command -v cargo >/dev/null 2>&1; then + echo "error: cargo not found in PATH" >&2 + exit 1 +fi + +cargo build --release --locked --manifest-path "$repo_root/Cargo.toml" --target-dir "$target_dir" + +if [[ ! -x "$built_binary" ]]; then + echo "error: built binary missing: $built_binary" >&2 + exit 1 +fi + +mkdir -p -- "$(dirname -- "$install_path")" +tmp="$(mktemp "${install_path}.tmp.XXXXXX")" +trap 'rm -f -- "$tmp"' EXIT + +cp -- "$built_binary" "$tmp" +chmod 0755 "$tmp" +mv -f -- "$tmp" "$install_path" +trap - EXIT + +echo "installed $install_path" +"$install_path" --version diff --git a/src/app.rs b/src/app.rs index eeaa669..d4d281c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,54 @@ pub enum JumpOutcome { NoOp, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NarrowTab { + Work, + Usage, + System, +} + +impl NarrowTab { + pub const ALL: [Self; 3] = [Self::Work, Self::Usage, Self::System]; + + pub fn label(self) -> &'static str { + match self { + Self::Work => "Work", + Self::Usage => "Usage", + Self::System => "System", + } + } + + pub fn shortcut(self) -> char { + match self { + Self::Work => 'w', + Self::Usage => 'u', + Self::System => 's', + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NarrowSection { + Sessions, + Projects, + Context, + Quota, + Tokens, + Ports, + Mcp, +} + +impl NarrowSection { + pub fn tab(self) -> NarrowTab { + match self { + Self::Sessions | Self::Projects => NarrowTab::Work, + Self::Context | Self::Quota | Self::Tokens => NarrowTab::Usage, + Self::Ports | Self::Mcp => NarrowTab::System, + } + } +} + pub struct App { pub sessions: Vec, pub selected: usize, @@ -71,6 +119,9 @@ pub struct App { pub show_ports: bool, pub show_sessions: bool, pub show_mcp: bool, + pub narrow_tab: NarrowTab, + pub active_narrow_section: Option, + pub maximized_narrow_section: Option, /// MCP servers detected on the most recent tick (sourced from /// MultiCollector). Populated regardless of `show_mcp` so panel /// toggling doesn't cost a discovery roundtrip. @@ -133,6 +184,9 @@ impl App { show_ports: panels.ports, show_sessions: panels.sessions, show_mcp: panels.mcp, + narrow_tab: NarrowTab::Work, + active_narrow_section: Some(NarrowSection::Sessions), + maximized_narrow_section: None, mcp_servers: Vec::new(), mcp_suppress_sessions: true, config_open: false, @@ -177,6 +231,7 @@ impl App { _ => return, } self.persist_panel_visibility(); + self.clamp_narrow_tab(); } /// Toggle whether mcp-server-owned rollouts are hidden from the @@ -249,6 +304,153 @@ impl App { _ => return, } self.persist_panel_visibility(); + self.clamp_narrow_tab(); + } + + pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool { + match tab { + NarrowTab::Work => self.show_sessions || self.show_projects, + NarrowTab::Usage => self.show_context || self.show_quota || self.show_tokens, + NarrowTab::System => self.show_ports || self.show_mcp, + } + } + + pub fn visible_narrow_tabs(&self) -> Vec { + NarrowTab::ALL + .into_iter() + .filter(|&tab| self.narrow_tab_visible(tab)) + .collect() + } + + pub fn active_narrow_tab(&self) -> Option { + if self.narrow_tab_visible(self.narrow_tab) { + Some(self.narrow_tab) + } else { + NarrowTab::ALL + .into_iter() + .find(|&tab| self.narrow_tab_visible(tab)) + } + } + + pub fn set_narrow_tab(&mut self, tab: NarrowTab) { + if self.narrow_tab_visible(tab) { + self.narrow_tab = tab; + self.clamp_narrow_section(); + } + } + + pub fn select_next_narrow_tab(&mut self) { + let tabs = self.visible_narrow_tabs(); + if tabs.is_empty() { + return; + } + let current = self.active_narrow_tab().unwrap_or(tabs[0]); + let pos = tabs.iter().position(|&tab| tab == current).unwrap_or(0); + self.narrow_tab = tabs[(pos + 1) % tabs.len()]; + self.clamp_narrow_section(); + } + + pub fn select_prev_narrow_tab(&mut self) { + let tabs = self.visible_narrow_tabs(); + if tabs.is_empty() { + return; + } + let current = self.active_narrow_tab().unwrap_or(tabs[0]); + let pos = tabs.iter().position(|&tab| tab == current).unwrap_or(0); + self.narrow_tab = tabs[(pos + tabs.len() - 1) % tabs.len()]; + self.clamp_narrow_section(); + } + + fn clamp_narrow_tab(&mut self) { + if let Some(tab) = self.active_narrow_tab() { + self.narrow_tab = tab; + } + self.clamp_narrow_section(); + } + + pub fn narrow_section_visible(&self, section: NarrowSection) -> bool { + match section { + NarrowSection::Sessions => self.show_sessions, + NarrowSection::Projects => self.show_projects, + NarrowSection::Context => self.show_context, + NarrowSection::Quota => self.show_quota, + NarrowSection::Tokens => self.show_tokens, + NarrowSection::Ports => self.show_ports, + NarrowSection::Mcp => self.show_mcp, + } + } + + pub fn visible_narrow_sections(&self, tab: NarrowTab) -> Vec { + let sections: &[NarrowSection] = match tab { + NarrowTab::Work => &[NarrowSection::Sessions, NarrowSection::Projects], + NarrowTab::Usage => &[ + NarrowSection::Context, + NarrowSection::Quota, + NarrowSection::Tokens, + ], + NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp], + }; + sections + .iter() + .copied() + .filter(|§ion| self.narrow_section_visible(section)) + .collect() + } + + pub fn active_narrow_section(&self) -> Option { + let tab = self.active_narrow_tab()?; + if let Some(section) = self.active_narrow_section { + if section.tab() == tab && self.narrow_section_visible(section) { + return Some(section); + } + } + self.visible_narrow_sections(tab).into_iter().next() + } + + pub fn set_active_narrow_section(&mut self, section: NarrowSection) { + if self.narrow_section_visible(section) { + self.narrow_tab = section.tab(); + self.active_narrow_section = Some(section); + self.clamp_narrow_section(); + } + } + + pub fn maximized_narrow_section(&self) -> Option { + let section = self.maximized_narrow_section?; + if self.active_narrow_tab() == Some(section.tab()) && self.narrow_section_visible(section) { + Some(section) + } else { + None + } + } + + pub fn toggle_narrow_section_zoom(&mut self, section: NarrowSection) { + if !self.narrow_section_visible(section) { + return; + } + self.set_active_narrow_section(section); + self.maximized_narrow_section = if self.maximized_narrow_section() == Some(section) { + None + } else { + Some(section) + }; + } + + pub fn maximize_active_narrow_section(&mut self) { + if let Some(section) = self.active_narrow_section() { + self.maximized_narrow_section = Some(section); + } + } + + pub fn restore_narrow_sections(&mut self) { + self.maximized_narrow_section = None; + } + + fn clamp_narrow_section(&mut self) { + self.active_narrow_section = self.active_narrow_section(); + if self.maximized_narrow_section().is_none() { + self.maximized_narrow_section = None; + } } pub fn toggle_timeline(&mut self) { @@ -474,6 +676,12 @@ impl App { } } + pub fn select_session(&mut self, index: usize) { + if index < self.sessions.len() && self.visible_indices().contains(&index) { + self.selected = index; + } + } + pub fn kill_selected(&mut self) { if self.sessions.is_empty() { return; diff --git a/src/main.rs b/src/main.rs index 2b0ca6b..289b84c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,10 @@ mod theme; mod ui; use app::{App, JumpOutcome}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEvent, + MouseEventKind, +}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; @@ -102,6 +105,7 @@ fn main() -> io::Result<()> { // Setup terminal enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; + stdout().execute(EnableMouseCapture)?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let app_result = run_app( @@ -114,11 +118,12 @@ fn main() -> io::Result<()> { ); // Always attempt both cleanup steps regardless of app result - let r1 = disable_raw_mode(); - let r2 = stdout().execute(LeaveAlternateScreen).map(|_| ()); + let r1 = stdout().execute(DisableMouseCapture).map(|_| ()); + let r2 = disable_raw_mode(); + let r3 = stdout().execute(LeaveAlternateScreen).map(|_| ()); // Return app error first, then cleanup errors - app_result.and(r1).and(r2) + app_result.and(r1).and(r2).and(r3) } fn run_app( @@ -145,8 +150,8 @@ fn run_app( // Poll at 500ms for smooth animations; data tick every 2s let had_input = if event::poll(render_interval)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { if app.help_open { // Any key dismisses help. app.help_open = false; @@ -187,6 +192,15 @@ fn run_app( KeyCode::Char('r') if !demo_mode => app.tick(), KeyCode::Down | KeyCode::Char('j') => app.select_next(), KeyCode::Up | KeyCode::Char('k') => app.select_prev(), + KeyCode::Right | KeyCode::Tab => app.select_next_narrow_tab(), + KeyCode::Left | KeyCode::BackTab => app.select_prev_narrow_tab(), + KeyCode::Char('w') => app.set_narrow_tab(app::NarrowTab::Work), + KeyCode::Char('u') => app.set_narrow_tab(app::NarrowTab::Usage), + KeyCode::Char('s') => app.set_narrow_tab(app::NarrowTab::System), + KeyCode::Char('+') | KeyCode::Char('=') => { + app.maximize_active_narrow_section() + } + KeyCode::Char('-') => app.restore_narrow_sections(), KeyCode::Char('x') if !demo_mode => app.kill_selected(), KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(), KeyCode::Char('t') => app.cycle_theme(), @@ -209,6 +223,12 @@ fn run_app( } } } + Event::Mouse(mouse) => { + let size = terminal.size()?; + let area = Rect::new(0, 0, size.width, size.height); + handle_mouse_event(&mut app, mouse, area); + } + _ => {} } true } else { @@ -234,6 +254,41 @@ fn run_app( Ok(()) } +fn handle_mouse_event(app: &mut App, mouse: MouseEvent, area: Rect) { + if app.help_open || app.view_open || app.config_open || app.filter_active { + return; + } + + match mouse.kind { + MouseEventKind::Down(_) => { + if let Some(target) = ui::click_target(app, area, mouse.column, mouse.row) { + match target { + ui::ClickTarget::NarrowTab(tab) => app.set_narrow_tab(tab), + ui::ClickTarget::NarrowSection(section) => { + app.set_active_narrow_section(section); + } + ui::ClickTarget::NarrowZoom(section) => { + app.toggle_narrow_section_zoom(section); + } + ui::ClickTarget::Session(index) => { + app.select_session(index); + app.set_active_narrow_section(app::NarrowSection::Sessions); + } + ui::ClickTarget::KillOrphanPorts => { + app.set_active_narrow_section(app::NarrowSection::Ports); + app.kill_orphan_ports(); + } + } + } + } + MouseEventKind::ScrollDown => app.select_next(), + MouseEventKind::ScrollUp => app.select_prev(), + MouseEventKind::ScrollRight => app.select_next_narrow_tab(), + MouseEventKind::ScrollLeft => app.select_prev_narrow_tab(), + _ => {} + } +} + /// Strip control characters (including ANSI escapes) and Unicode bidi /// overrides from a string for safe terminal output. Defeats CVE-2021-42574 /// (Trojan Source) style attacks via RTLO/LRO/PDF/isolate characters. diff --git a/src/ui/context.rs b/src/ui/context.rs index 590be4b..bb9f85b 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -8,13 +8,24 @@ use ratatui::widgets::{Cell, Paragraph, Row, Table}; use ratatui::Frame; use super::{ - braille_graph_multirow, btop_block, fmt_tokens, grad_at, make_gradient, meter_bar, truncate_str, + braille_graph_multirow, btop_block_active, fmt_tokens, grad_at, make_gradient, meter_bar, + truncate_str, }; pub(crate) fn draw_context_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_context_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_context_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { let cpu_grad = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); - let block = btop_block("context", "¹", theme.cpu_box, theme); + let block = btop_block_active("context", "¹", theme.cpu_box, theme, active); f.render_widget(block, area); let inner = Rect { diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 9f2236e..1c30150 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -8,38 +8,51 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use super::truncate_str; + pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { // Filter input mode: show filter bar instead of normal keybindings if app.filter_active { let visible_count = app.visible_indices().len(); + let count = format!( + "{}/{} {}", + visible_count, + app.sessions.len(), + t("footer.sessions") + ); + let suffix = if area.width <= 80 { + format!("_ {}", count) + } else { + format!( + "_ {} (Esc {}, Enter {})", + count, + t("footer.esc_clear") + .split(',') + .next() + .unwrap_or(&t("footer.esc_clear")), + t("footer.esc_clear") + .split(',') + .nth(1) + .unwrap_or("keep") + .trim() + ) + }; + let filter_w = (area.width as usize).saturating_sub(2 + suffix.chars().count()); let spans = vec![ Span::styled(" /", Style::default().fg(theme.hi_fg)), - Span::styled(&app.filter_text, Style::default().fg(theme.title)), - Span::styled("_", Style::default().fg(theme.hi_fg)), Span::styled( - format!( - " {}/{} {} (Esc {}, Enter {})", - visible_count, - app.sessions.len(), - t("footer.sessions"), - t("footer.esc_clear") - .split(',') - .next() - .unwrap_or(&t("footer.esc_clear")), - t("footer.esc_clear") - .split(',') - .nth(1) - .unwrap_or("keep") - .trim() - ), - Style::default().fg(theme.inactive_fg), + truncate_str(&app.filter_text, filter_w), + Style::default().fg(theme.title), ), + Span::styled(suffix, Style::default().fg(theme.inactive_fg)), ]; f.render_widget(Paragraph::new(Line::from(spans)), area); return; } let has_tmux = std::env::var("TMUX").is_ok(); + let compact = area.width <= 80; + let ultra_compact = area.width <= 70; let mut spans = vec![ Span::styled(" ↑↓", Style::default().fg(theme.hi_fg)), @@ -48,38 +61,48 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { Style::default().fg(theme.main_fg), ), ]; - if has_tmux { + if has_tmux && !ultra_compact { spans.push(Span::styled("↵", Style::default().fg(theme.hi_fg))); spans.push(Span::styled( format!(" {} ", t("footer.jump")), Style::default().fg(theme.main_fg), )); } - spans.push(Span::styled("x", Style::default().fg(theme.hi_fg))); - spans.push(Span::styled( - format!(" {} ", t("footer.kill")), - Style::default().fg(theme.main_fg), - )); + if compact { + spans.push(Span::styled("←→", Style::default().fg(theme.hi_fg))); + spans.push(Span::styled(" tabs ", Style::default().fg(theme.main_fg))); + } + if !ultra_compact { + spans.push(Span::styled("x", Style::default().fg(theme.hi_fg))); + spans.push(Span::styled( + format!(" {} ", t("footer.kill")), + Style::default().fg(theme.main_fg), + )); + } spans.push(Span::styled("/", Style::default().fg(theme.hi_fg))); spans.push(Span::styled( format!(" {} ", t("footer.filter")), Style::default().fg(theme.main_fg), )); - spans.push(Span::styled("v", Style::default().fg(theme.hi_fg))); - spans.push(Span::styled( - format!(" {} ", t("footer.view")), - Style::default().fg(theme.main_fg), - )); - spans.push(Span::styled("c", Style::default().fg(theme.hi_fg))); - spans.push(Span::styled( - format!(" {} ", t("footer.config")), - Style::default().fg(theme.main_fg), - )); - spans.push(Span::styled("?", Style::default().fg(theme.hi_fg))); - spans.push(Span::styled( - format!(" {} ", t("footer.help")), - Style::default().fg(theme.main_fg), - )); + if !ultra_compact { + spans.push(Span::styled("v", Style::default().fg(theme.hi_fg))); + spans.push(Span::styled( + format!(" {} ", t("footer.view")), + Style::default().fg(theme.main_fg), + )); + if !compact { + spans.push(Span::styled("c", Style::default().fg(theme.hi_fg))); + spans.push(Span::styled( + format!(" {} ", t("footer.config")), + Style::default().fg(theme.main_fg), + )); + } + spans.push(Span::styled("?", Style::default().fg(theme.hi_fg))); + spans.push(Span::styled( + format!(" {} ", t("footer.help")), + Style::default().fg(theme.main_fg), + )); + } spans.push(Span::styled("q", Style::default().fg(theme.hi_fg))); spans.push(Span::styled( format!(" {} ", t("footer.quit")), @@ -87,12 +110,12 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { )); // Show active filter or transient status - if !app.filter_text.is_empty() { + if !compact && !app.filter_text.is_empty() { spans.push(Span::styled( format!(" /{} ", app.filter_text), Style::default().fg(theme.status_fg), )); - } else { + } else if !compact { let status_text = app .status_msg .as_ref() @@ -126,7 +149,7 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { None } }; - if let Some(ref peak) = peak_info { + if let Some(ref peak) = peak_info.filter(|_| !compact) { spans.push(Span::styled( format!(" {peak} "), Style::default().fg(theme.warning_fg), @@ -145,12 +168,15 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { } else { format!("{} {}", app.sessions.len(), sessions_label) }; - let used: usize = spans.iter().map(|s| s.content.len()).sum(); - let remaining = (area.width as usize).saturating_sub(used + 2); - spans.push(Span::styled( - format!("{:>width$}", count_label, width = remaining), - Style::default().fg(theme.graph_text), - )); + let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + let count_w = count_label.chars().count(); + if used + count_w < area.width as usize { + let remaining = (area.width as usize).saturating_sub(used + count_w); + spans.push(Span::styled( + format!("{:>width$}", count_label, width = remaining), + Style::default().fg(theme.graph_text), + )); + } f.render_widget(Paragraph::new(Line::from(spans)), area); } diff --git a/src/ui/header.rs b/src/ui/header.rs index d117bc9..3745338 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -23,7 +23,11 @@ pub(crate) fn draw_header(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { // Width budget: prefer host + agents; fall back to agents-only; then to nothing. let width = area.width as usize; let base = title.len() + right.len() + 4; // 4 = separators / padding - let (host_render, agent_render) = pick_metrics(host_str.as_deref(), &agent_str, width, base); + let (host_render, agent_render) = if width <= 80 { + (None, None) + } else { + pick_metrics(host_str.as_deref(), &agent_str, width, base) + }; let mut spans: Vec = Vec::with_capacity(8); spans.push(Span::styled( diff --git a/src/ui/mcp.rs b/src/ui/mcp.rs index 2d0bc49..e0fcca4 100644 --- a/src/ui/mcp.rs +++ b/src/ui/mcp.rs @@ -9,9 +9,19 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use std::time::SystemTime; -use super::{btop_block, fmt_age, grad_at, make_gradient}; +use super::{btop_block_active, fmt_age, grad_at, make_gradient}; pub(crate) fn draw_mcp_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_mcp_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_mcp_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { let header_style = Style::default() .fg(theme.main_fg) .add_modifier(Modifier::BOLD); @@ -32,7 +42,7 @@ pub(crate) fn draw_mcp_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme format!(" {}", no_servers), Style::default().fg(theme.inactive_fg), ))); - let block = btop_block("mcp servers", "⁷", theme.net_box, theme); + let block = btop_block_active("mcp servers", "⁷", theme.net_box, theme, active); f.render_widget(Paragraph::new(lines).block(block), area); return; } @@ -92,7 +102,6 @@ pub(crate) fn draw_mcp_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme ))); } - let block = btop_block("mcp servers", "⁷", theme.net_box, theme); + let block = btop_block_active("mcp servers", "⁷", theme.net_box, theme, active); f.render_widget(Paragraph::new(lines).block(block), area); } - diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9ee2f43..ab683a1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,7 +11,7 @@ mod sessions; mod tokens; mod view_menu; -use crate::app::App; +use crate::app::{App, NarrowSection, NarrowTab}; use crate::locale::t; use crate::theme::Theme; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -238,12 +238,18 @@ pub(crate) fn braille_graph_multirow( // ── btop-style block with notch title: ──┐¹title┌────── ───────────────────── -pub(crate) fn btop_block( +pub(crate) fn btop_block_active( title: &str, number: &str, box_color: Color, theme: &Theme, + active: bool, ) -> Block<'static> { + let title = if active { + format!("{title}(*)") + } else { + title.to_string() + }; Block::default() .title(Line::from(vec![ Span::styled("┐", Style::default().fg(box_color)), @@ -254,7 +260,7 @@ pub(crate) fn btop_block( .add_modifier(Modifier::BOLD), ), Span::styled( - title.to_string(), + title, Style::default() .fg(theme.title) .add_modifier(Modifier::BOLD), @@ -272,8 +278,26 @@ pub(crate) fn styled_label(text: &str, graph_text: Color) -> Span<'static> { // ── main draw ──────────────────────────────────────────────────────────────── -const MIN_WIDTH: u16 = 100; -const MIN_HEIGHT: u16 = 24; +const MIN_WIDTH: u16 = 60; +const MIN_HEIGHT: u16 = 18; +pub(crate) const DESKTOP_WIDTH: u16 = 100; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ClickTarget { + NarrowTab(NarrowTab), + NarrowSection(NarrowSection), + NarrowZoom(NarrowSection), + Session(usize), + KillOrphanPorts, +} + +struct DesktopLayout { + header: Rect, + context: Option, + mid: Vec<(NarrowSection, Rect)>, + sessions: Option, + footer: Rect, +} pub fn draw(f: &mut Frame, app: &App) { let theme = &app.theme; @@ -353,14 +377,61 @@ pub fn draw(f: &mut Frame, app: &App) { return; } - // Layout priority: sessions first → mid → context (only with surplus space) + if w < DESKTOP_WIDTH { + draw_narrow(f, app, area, theme); + draw_overlays(f, app, theme); + return; + } + + let layout = desktop_layout(app, area); + header::draw_header(f, app, layout.header, theme); + + if let Some(area) = layout.context { + context::draw_context_panel(f, app, area, theme); + } + + for (section, area) in layout.mid { + match section { + NarrowSection::Quota => quota::draw_quota_panel(f, app, area, theme), + NarrowSection::Tokens => tokens::draw_tokens_panel(f, app, area, theme), + NarrowSection::Projects => projects::draw_projects_panel(f, app, area, theme), + NarrowSection::Ports => ports::draw_ports_panel(f, app, area, theme), + NarrowSection::Mcp => mcp::draw_mcp_panel(f, app, area, theme), + NarrowSection::Sessions | NarrowSection::Context => {} + } + } + + if let Some(area) = layout.sessions { + sessions::draw_sessions_panel(f, app, area, theme); + } + footer::draw_footer(f, app, layout.footer, theme); + + draw_overlays(f, app, theme); +} +fn desktop_layout(app: &App, area: Rect) -> DesktopLayout { const CONTEXT_MIN: u16 = 5; const FIXED: u16 = 2; // header + footer + const MID_MIN: u16 = 6; - let any_mid = - app.show_quota || app.show_tokens || app.show_projects || app.show_ports || app.show_mcp; + let mut mid_sections = Vec::new(); + if app.show_quota { + mid_sections.push(NarrowSection::Quota); + } + if app.show_tokens { + mid_sections.push(NarrowSection::Tokens); + } + if app.show_projects { + mid_sections.push(NarrowSection::Projects); + } + if app.show_ports { + mid_sections.push(NarrowSection::Ports); + } + if app.show_mcp { + mid_sections.push(NarrowSection::Mcp); + } + let any_mid = !mid_sections.is_empty(); let mid_h_ideal: u16 = 8; let sessions_ideal: u16 = if app.show_sessions { (app.sessions.len() as u16 * 2 + 7).max(8) @@ -369,8 +440,7 @@ pub fn draw(f: &mut Frame, app: &App) { }; let context_ideal: u16 = (app.sessions.len() as u16 + 4).clamp(5, 10); - let available = h.saturating_sub(FIXED); - const MID_MIN: u16 = 6; + let available = area.height.saturating_sub(FIXED); let mid_reserved = if any_mid { MID_MIN.min(available) } else { 0 }; let sessions_budget = available.saturating_sub(mid_reserved); let sessions_h = if app.show_sessions { @@ -400,7 +470,7 @@ pub fn draw(f: &mut Frame, app: &App) { let mut constraints = [Constraint::Length(0); 5]; let mut n = 0; constraints[n] = Constraint::Length(1); - n += 1; // header + n += 1; if context_h > 0 { constraints[n] = Constraint::Length(context_h); n += 1; @@ -413,7 +483,7 @@ pub fn draw(f: &mut Frame, app: &App) { constraints[n] = Constraint::Min(sessions_h); n += 1; } - constraints[n] = Constraint::Length(1); // footer + constraints[n] = Constraint::Length(1); n += 1; let chunks = Layout::default() @@ -422,69 +492,214 @@ pub fn draw(f: &mut Frame, app: &App) { .split(area); let mut idx = 0; - header::draw_header(f, app, chunks[idx], theme); + let header = chunks[idx]; idx += 1; - if context_h > 0 { - context::draw_context_panel(f, app, chunks[idx], theme); + let context = if context_h > 0 { + let area = chunks[idx]; idx += 1; - } + Some(area) + } else { + None + }; - if mid_h > 0 { - let mut mid_constraints: Vec = Vec::new(); - if app.show_quota { - mid_constraints.push(Constraint::Length(0)); - } - if app.show_tokens { - mid_constraints.push(Constraint::Length(0)); - } - if app.show_projects { - mid_constraints.push(Constraint::Length(0)); - } - if app.show_ports { - mid_constraints.push(Constraint::Length(0)); - } - if app.show_mcp { - mid_constraints.push(Constraint::Length(0)); - } - let count = mid_constraints.len() as u32; + let mid = if mid_h > 0 { + let count = mid_sections.len() as u32; let mid_constraints: Vec = (0..count).map(|_| Constraint::Ratio(1, count)).collect(); - let mid_panels = Layout::default() .direction(Direction::Horizontal) .constraints(mid_constraints) .split(chunks[idx]); + idx += 1; + mid_sections + .into_iter() + .zip(mid_panels.iter().copied()) + .collect() + } else { + Vec::new() + }; - let mut mi = 0; - if app.show_quota { - quota::draw_quota_panel(f, app, mid_panels[mi], theme); - mi += 1; - } - if app.show_tokens { - tokens::draw_tokens_panel(f, app, mid_panels[mi], theme); - mi += 1; + let sessions = if sessions_h > 0 { + let area = chunks[idx]; + idx += 1; + Some(area) + } else { + None + }; + + DesktopLayout { + header, + context, + mid, + sessions, + footer: chunks[idx], + } +} + +fn draw_narrow(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + header::draw_header(f, app, chunks[0], theme); + + let body = chunks[1]; + draw_active_narrow_panel(f, app, body, theme); + + draw_narrow_tabs(f, app, chunks[2], theme); + footer::draw_footer(f, app, chunks[3], theme); +} + +fn draw_narrow_tabs(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + let active = app.active_narrow_tab(); + let tab_areas = narrow_tab_layout(app, area); + let used = narrow_tab_group_width(&tab_areas); + let pad = area.width.saturating_sub(used) as usize; + let mut spans: Vec = Vec::new(); + if pad > 0 { + spans.push(Span::styled( + " ".repeat(pad), + Style::default().bg(theme.main_bg), + )); + } + for (i, (tab, _)) in tab_areas.into_iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", Style::default().bg(theme.main_bg))); } - if app.show_projects { - projects::draw_projects_panel(f, app, mid_panels[mi], theme); - mi += 1; + let selected = Some(tab) == active; + let mut style = Style::default() + .bg(if selected { + theme.selected_bg + } else { + theme.main_bg + }) + .fg(if selected { + theme.selected_fg + } else { + theme.inactive_fg + }); + if selected { + style = style.add_modifier(Modifier::BOLD); + }; + spans.push(Span::styled(narrow_tab_label(tab), style)); + } + + f.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(theme.main_bg)), + area, + ); +} + +fn narrow_tab_label(tab: NarrowTab) -> String { + format!(" {}({}) ", tab.label(), tab.shortcut()) +} + +fn narrow_tab_width(tab: NarrowTab) -> u16 { + narrow_tab_label(tab).chars().count() as u16 +} + +fn narrow_tab_group_width(tab_areas: &[(NarrowTab, Rect)]) -> u16 { + let labels = tab_areas.iter().map(|(_, area)| area.width).sum::(); + labels + tab_areas.len().saturating_sub(1) as u16 +} + +fn draw_active_narrow_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + let Some(tab) = app.active_narrow_tab() else { + return; + }; + + for (section, section_area) in narrow_section_areas(app, tab, area) { + draw_narrow_section(f, app, section_area, theme, section); + draw_narrow_zoom_button(f, app, section_area, theme, section); + } +} + +fn narrow_section_areas(app: &App, tab: NarrowTab, area: Rect) -> Vec<(NarrowSection, Rect)> { + let sections = if let Some(section) = app.maximized_narrow_section() { + if section.tab() == tab { + vec![section] + } else { + app.visible_narrow_sections(tab) } - if app.show_ports { - ports::draw_ports_panel(f, app, mid_panels[mi], theme); - mi += 1; + } else { + app.visible_narrow_sections(tab) + }; + if sections.is_empty() { + return Vec::new(); + } + + if sections.len() == 1 { + return vec![(sections[0], area)]; + } + + let count = sections.len() as u32; + let constraints: Vec = (0..count).map(|_| Constraint::Ratio(1, count)).collect(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + sections.into_iter().zip(chunks.iter().copied()).collect() +} + +fn draw_narrow_section( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + section: NarrowSection, +) { + let active = app.active_narrow_section() == Some(section); + match section { + NarrowSection::Sessions => { + sessions::draw_sessions_panel_active(f, app, area, theme, active) } - if app.show_mcp { - mcp::draw_mcp_panel(f, app, mid_panels[mi], theme); + NarrowSection::Projects => { + projects::draw_projects_panel_active(f, app, area, theme, active) } - idx += 1; + NarrowSection::Context => context::draw_context_panel_active(f, app, area, theme, active), + NarrowSection::Quota => quota::draw_quota_panel_active(f, app, area, theme, active), + NarrowSection::Tokens => tokens::draw_tokens_panel_active(f, app, area, theme, active), + NarrowSection::Ports => ports::draw_ports_panel_active(f, app, area, theme, active), + NarrowSection::Mcp => mcp::draw_mcp_panel_active(f, app, area, theme, active), } +} - if sessions_h > 0 { - sessions::draw_sessions_panel(f, app, chunks[idx], theme); - idx += 1; - } - footer::draw_footer(f, app, chunks[idx], theme); +fn draw_narrow_zoom_button( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + section: NarrowSection, +) { + let Some(button_area) = zoom_button_area(area) else { + return; + }; + let label = if app.maximized_narrow_section() == Some(section) { + " - " + } else { + " + " + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + label, + Style::default() + .bg(theme.main_bg) + .fg(theme.hi_fg) + .add_modifier(Modifier::BOLD), + ))), + button_area, + ); +} +fn draw_overlays(f: &mut Frame, app: &App, theme: &Theme) { if app.config_open { config::draw_config_overlay(f, app, theme); } @@ -496,6 +711,228 @@ pub fn draw(f: &mut Frame, app: &App) { } } +pub(crate) fn click_target(app: &App, area: Rect, column: u16, row: u16) -> Option { + if area.width >= DESKTOP_WIDTH { + let layout = desktop_layout(app, area); + if let Some(sessions_area) = layout.sessions { + if contains(sessions_area, column, row) { + return session_at(app, sessions_area, row).map(ClickTarget::Session); + } + } + for (section, section_area) in layout.mid { + if section == NarrowSection::Ports + && contains(section_area, column, row) + && ports_kill_at(app, section_area, row) + { + return Some(ClickTarget::KillOrphanPorts); + } + } + return None; + } + + let chunks = narrow_chunks(area); + if contains(chunks[2], column, row) { + return narrow_tab_at(app, chunks[2], column).map(ClickTarget::NarrowTab); + } + + let tab = app.active_narrow_tab()?; + + if contains(chunks[1], column, row) { + for (section, section_area) in narrow_section_areas(app, tab, chunks[1]) { + if !contains(section_area, column, row) { + continue; + } + if zoom_button_at(section_area, column, row) { + return Some(ClickTarget::NarrowZoom(section)); + } + if section == NarrowSection::Sessions { + if let Some(index) = session_at(app, section_area, row) { + return Some(ClickTarget::Session(index)); + } + } + if section == NarrowSection::Ports && ports_kill_at(app, section_area, row) { + return Some(ClickTarget::KillOrphanPorts); + } + return Some(ClickTarget::NarrowSection(section)); + } + } + + None +} + +fn zoom_button_area(area: Rect) -> Option { + if area.width < 5 || area.height == 0 { + return None; + } + Some(Rect { + x: area.x + area.width - 4, + y: area.y, + width: 3, + height: 1, + }) +} + +fn zoom_button_at(area: Rect, column: u16, row: u16) -> bool { + zoom_button_area(area).is_some_and(|button| contains(button, column, row)) +} + +fn narrow_chunks(area: Rect) -> Vec { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area) + .to_vec() +} + +fn narrow_tab_at(app: &App, area: Rect, column: u16) -> Option { + for (tab, tab_area) in narrow_tab_layout(app, area) { + if contains(tab_area, column, area.y) { + return Some(tab); + } + } + None +} + +fn narrow_tab_layout(app: &App, area: Rect) -> Vec<(NarrowTab, Rect)> { + let tabs = app.visible_narrow_tabs(); + if tabs.is_empty() { + return Vec::new(); + } + let labels_width = tabs.iter().map(|&tab| narrow_tab_width(tab)).sum::(); + let gaps = tabs.len().saturating_sub(1) as u16; + let total = labels_width.saturating_add(gaps).min(area.width); + let mut x = area.x + area.width.saturating_sub(total); + let mut out = Vec::with_capacity(tabs.len()); + for (i, tab) in tabs.into_iter().enumerate() { + if i > 0 { + x = x.saturating_add(1); + } + let width = narrow_tab_width(tab).min(area.x + area.width - x); + if width == 0 { + break; + } + out.push(( + tab, + Rect { + x, + y: area.y, + width, + height: 1, + }, + )); + x = x.saturating_add(width); + if x >= area.x + area.width { + break; + } + } + out +} + +fn session_at(app: &App, area: Rect, row: u16) -> Option { + if area.height < 4 || row <= area.y + 1 { + return None; + } + + let inner_h = area.height.saturating_sub(2); + let visible = app.visible_indices(); + let session_rows: u16 = visible + .iter() + .map(|&i| { + let base = 2u16; + if app.tree_view { + base + app.sessions[i].subagents.len() as u16 + } else { + base + } + }) + .sum(); + let detail_reserve: u16 = if app.show_timeline { + (inner_h * 2 / 3).min(inner_h.saturating_sub(5)) + } else if inner_h <= 12 { + 6.min(inner_h.saturating_sub(3)) + } else { + 10.min(inner_h / 2) + }; + let max_table = inner_h.saturating_sub(detail_reserve); + let table_h = (1 + session_rows).min(max_table); + let table_y = area.y + 1; + if row >= table_y.saturating_add(table_h) { + return None; + } + + let visible_rows = table_h.saturating_sub(1) as usize; + let selected_pos = visible.iter().position(|&i| i == app.selected).unwrap_or(0); + let selected_row_start: usize = visible + .iter() + .take(selected_pos) + .map(|&i| { + let base = 2; + if app.tree_view { + base + app.sessions[i].subagents.len() + } else { + base + } + }) + .sum(); + let selected_session_rows = if app.tree_view { + 2 + app + .sessions + .get(app.selected) + .map_or(0, |s| s.subagents.len()) + } else { + 2 + }; + let scroll_offset = (selected_row_start + selected_session_rows).saturating_sub(visible_rows); + let target_row = scroll_offset + row.saturating_sub(table_y + 1) as usize; + let mut offset = 0usize; + for &idx in &visible { + let rows = if app.tree_view { + 2 + app.sessions[idx].subagents.len() + } else { + 2 + }; + if target_row >= offset && target_row < offset + rows { + return Some(idx); + } + offset += rows; + } + + None +} + +fn ports_kill_at(app: &App, area: Rect, row: u16) -> bool { + if app.orphan_ports.is_empty() || area.height < 3 { + return false; + } + + let live_ports = app + .sessions + .iter() + .map(|session| { + session + .children + .iter() + .filter(|child| child.port.is_some()) + .count() + }) + .sum::() as u16; + let kill_line = 1 + live_ports + app.orphan_ports.len() as u16; + let kill_row = area.y + 1 + kill_line; + row == kill_row && kill_row < area.y + area.height.saturating_sub(1) +} + +fn contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + // ── utility functions ──────────────────────────────────────────────────────── pub(crate) fn fmt_mem_kb(kb: u64) -> String { @@ -546,6 +983,9 @@ pub(crate) fn truncate_str(s: &str, max: usize) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::PanelVisibility; + use ratatui::backend::TestBackend; + use ratatui::Terminal; #[test] fn fmt_age_buckets() { @@ -560,4 +1000,340 @@ mod tests { // because it formatted seconds without unit conversion. assert_eq!(fmt_age(341_493), "3d ago"); } + + #[test] + fn compact_sizes_render_sessions_instead_of_too_small() { + for (w, h) in [(69, 27), (80, 24)] { + let text = render_demo(w, h); + assert!(text.contains("Work"), "{w}x{h} should render tabs\n{text}"); + assert!( + text.contains("Usage"), + "{w}x{h} should expose grouped panels as tabs\n{text}" + ); + assert!( + text.contains("System(s)"), + "{w}x{h} should render system tab shortcut\n{text}" + ); + assert!( + text.contains("sessions"), + "{w}x{h} should render sessions panel\n{text}" + ); + assert!( + text.contains("sessions(*)"), + "{w}x{h} should mark the active section in the title\n{text}" + ); + assert!( + text.contains("projects"), + "{w}x{h} should pair sessions with projects\n{text}" + ); + assert!( + text.contains("SESSION"), + "{w}x{h} should render selected-session detail\n{text}" + ); + assert!( + !text.contains("Terminal size too small"), + "{w}x{h} should be supported\n{text}" + ); + assert!( + !text.contains("quota"), + "{w}x{h} should not spend first screen on mid panels\n{text}" + ); + } + } + + #[test] + fn compact_tab_switch_renders_selected_panel() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + app.set_narrow_tab(NarrowTab::Usage); + + let backend = TestBackend::new(69, 27); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + + assert!( + text.contains("quota"), + "usage tab should render quota panel\n{text}" + ); + assert!( + text.contains("tokens"), + "usage tab should render tokens panel\n{text}" + ); + assert!( + !text.contains("SESSION"), + "usage tab should not keep sessions detail in body\n{text}" + ); + } + + #[test] + fn compact_click_targets_tabs_and_sessions() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + let area = Rect { + x: 0, + y: 0, + width: 69, + height: 27, + }; + let chunks = narrow_chunks(area); + let tab_area = chunks[2]; + let tab_areas = narrow_tab_layout(&app, tab_area); + let usage_area = tab_areas + .iter() + .find(|(tab, _)| *tab == NarrowTab::Usage) + .map(|(_, area)| *area) + .unwrap(); + let separator_x = usage_area.x - 1; + + assert_eq!( + click_target(&app, area, usage_area.x, tab_area.y), + Some(ClickTarget::NarrowTab(NarrowTab::Usage)) + ); + assert_eq!(click_target(&app, area, separator_x, tab_area.y), None); + assert_eq!(click_target(&app, area, 3, tab_area.y), None); + assert_eq!( + click_target(&app, area, 3, 4), + Some(ClickTarget::Session(0)) + ); + + assert_eq!( + click_target(&app, area, 3, 16), + Some(ClickTarget::NarrowSection(NarrowSection::Projects)) + ); + + let sessions_area = narrow_section_areas(&app, NarrowTab::Work, chunks[1]) + .into_iter() + .find(|(section, _)| *section == NarrowSection::Sessions) + .map(|(_, area)| area) + .unwrap(); + assert_eq!( + click_target( + &app, + area, + sessions_area.x + sessions_area.width - 3, + sessions_area.y + ), + Some(ClickTarget::NarrowZoom(NarrowSection::Sessions)) + ); + } + + #[test] + fn compact_tabs_highlight_only_active_tab() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + let area = Rect { + x: 0, + y: 0, + width: 69, + height: 27, + }; + let tab_area = narrow_chunks(area)[2]; + let tab_areas = narrow_tab_layout(&app, tab_area); + + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + let work = tab_areas + .iter() + .find(|(tab, _)| *tab == NarrowTab::Work) + .map(|(_, area)| *area) + .unwrap(); + let usage = tab_areas + .iter() + .find(|(tab, _)| *tab == NarrowTab::Usage) + .map(|(_, area)| *area) + .unwrap(); + let system = tab_areas + .iter() + .find(|(tab, _)| *tab == NarrowTab::System) + .map(|(_, area)| *area) + .unwrap(); + + assert_eq!( + buffer.cell((work.x, work.y)).unwrap().bg, + app.theme.selected_bg + ); + assert_eq!( + buffer.cell((usage.x, usage.y)).unwrap().bg, + app.theme.main_bg + ); + assert_eq!( + buffer.cell((system.x, system.y)).unwrap().bg, + app.theme.main_bg + ); + assert_eq!( + buffer.cell((work.x, work.y)).unwrap().fg, + app.theme.selected_fg + ); + assert_eq!( + buffer.cell((usage.x, usage.y)).unwrap().fg, + app.theme.inactive_fg + ); + assert_eq!( + buffer.cell((usage.x - 1, usage.y)).unwrap().bg, + app.theme.main_bg + ); + assert_eq!( + buffer.cell((system.x - 1, system.y)).unwrap().bg, + app.theme.main_bg + ); + } + + #[test] + fn compact_sections_split_evenly_and_ports_kill_is_clickable() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + let area = Rect { + x: 0, + y: 0, + width: 69, + height: 27, + }; + let body = narrow_chunks(area)[1]; + + let usage_sections = narrow_section_areas(&app, NarrowTab::Usage, body); + assert_eq!(usage_sections.len(), 3); + let min_h = usage_sections + .iter() + .map(|(_, area)| area.height) + .min() + .unwrap(); + let max_h = usage_sections + .iter() + .map(|(_, area)| area.height) + .max() + .unwrap(); + assert!(max_h - min_h <= 1, "usage sections should be even"); + + app.set_narrow_tab(NarrowTab::System); + let ports_area = narrow_section_areas(&app, NarrowTab::System, body) + .into_iter() + .find(|(section, _)| *section == NarrowSection::Ports) + .map(|(_, area)| area) + .unwrap(); + let live_ports = app + .sessions + .iter() + .map(|session| { + session + .children + .iter() + .filter(|child| child.port.is_some()) + .count() + }) + .sum::() as u16; + let kill_row = ports_area.y + 1 + 1 + live_ports + app.orphan_ports.len() as u16; + assert_eq!( + click_target(&app, area, ports_area.x + 2, kill_row), + Some(ClickTarget::KillOrphanPorts) + ); + } + + #[test] + fn compact_zoom_renders_only_selected_section() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + let area = Rect { + x: 0, + y: 0, + width: 69, + height: 27, + }; + let body = narrow_chunks(area)[1]; + + app.toggle_narrow_section_zoom(NarrowSection::Quota); + let sections = narrow_section_areas(&app, NarrowTab::Usage, body); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0], (NarrowSection::Quota, body)); + + let backend = TestBackend::new(69, 27); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + assert!( + text.contains("quota(*)"), + "zoomed section should stay active\n{text}" + ); + assert!( + !text.contains("tokens"), + "zoomed tab should hide peer sections\n{text}" + ); + } + + #[test] + fn desktop_click_targets_sessions_and_ports() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + for session in &mut app.sessions { + session.children.clear(); + } + let area = Rect { + x: 0, + y: 0, + width: 120, + height: 40, + }; + let layout = desktop_layout(&app, area); + let sessions_area = layout.sessions.unwrap(); + assert_eq!( + click_target(&app, area, sessions_area.x + 2, sessions_area.y + 2), + Some(ClickTarget::Session(0)) + ); + + let ports_area = layout + .mid + .iter() + .find(|(section, _)| *section == NarrowSection::Ports) + .map(|(_, area)| *area) + .unwrap(); + let kill_row = ports_area.y + 1 + 1 + app.orphan_ports.len() as u16; + assert_eq!( + click_target(&app, area, ports_area.x + 2, kill_row), + Some(ClickTarget::KillOrphanPorts) + ); + } + + #[test] + fn desktop_timeline_uses_full_width_when_left_detail_is_empty() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + app.sessions[app.selected].children.clear(); + app.sessions[app.selected].subagents.clear(); + + let backend = TestBackend::new(160, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + let timeline_col = text + .lines() + .find_map(|line| line.find("TIMELINE")) + .expect("timeline should render"); + assert!( + timeline_col < 20, + "timeline should use the empty left detail area\n{text}" + ); + } + + #[test] + fn desktop_size_keeps_mid_panels() { + let text = render_demo(120, 40); + for label in ["quota", "tokens", "projects", "ports", "sessions"] { + assert!( + text.contains(label), + "desktop should render {label}\n{text}" + ); + } + } + + fn render_demo(width: u16, height: u16) -> String { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + format!("{}", terminal.backend()) + } } diff --git a/src/ui/ports.rs b/src/ui/ports.rs index 80483e8..4dca9e3 100644 --- a/src/ui/ports.rs +++ b/src/ui/ports.rs @@ -7,9 +7,19 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use super::{btop_block, grad_at, make_gradient}; +use super::{btop_block_active, grad_at, make_gradient}; pub(crate) fn draw_ports_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_ports_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_ports_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { // Collect (port, project_name, session_id_short) let mut all_ports: Vec<(u16, String, String)> = Vec::new(); for session in &app.sessions { @@ -93,6 +103,6 @@ pub(crate) fn draw_ports_panel(f: &mut Frame, app: &App, area: Rect, theme: &The ))); } - let block = btop_block("ports", "⁵", theme.net_box, theme); + let block = btop_block_active("ports", "⁵", theme.net_box, theme, active); f.render_widget(Paragraph::new(lines).block(block), area); } diff --git a/src/ui/projects.rs b/src/ui/projects.rs index 0725325..5010e75 100644 --- a/src/ui/projects.rs +++ b/src/ui/projects.rs @@ -7,9 +7,19 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use super::{btop_block, grad_at, make_gradient, truncate_str}; +use super::{btop_block_active, grad_at, make_gradient, truncate_str}; pub(crate) fn draw_projects_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_projects_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_projects_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { let mut lines = Vec::new(); let mut seen = std::collections::HashSet::new(); let no_git = t("projects.no_git"); @@ -77,6 +87,6 @@ pub(crate) fn draw_projects_panel(f: &mut Frame, app: &App, area: Rect, theme: & ))); } - let block = btop_block("projects", "⁴", theme.mem_box, theme); + let block = btop_block_active("projects", "⁴", theme.mem_box, theme, active); f.render_widget(Paragraph::new(lines).block(block), area); } diff --git a/src/ui/quota.rs b/src/ui/quota.rs index d1c6a12..72b19e0 100644 --- a/src/ui/quota.rs +++ b/src/ui/quota.rs @@ -8,7 +8,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use super::{btop_block, fmt_tokens, grad_at, make_gradient, remaining_bar, styled_label}; +use super::{btop_block_active, fmt_tokens, grad_at, make_gradient, remaining_bar, styled_label}; /// Data considered "stale" when its updated_at is older than this many seconds. const STALE_SECS: u64 = 600; @@ -17,9 +17,19 @@ const STALE_SECS: u64 = 600; const SOURCES: &[&str] = &["claude", "codex"]; pub(crate) fn draw_quota_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_quota_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_quota_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { let cpu_grad = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); - let block = btop_block("quota", "²", theme.cpu_box, theme); + let block = btop_block_active("quota", "²", theme.cpu_box, theme, active); f.render_widget(block, area); let inner = Rect { diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index 359f9f2..657543c 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -8,11 +8,21 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Cell, Paragraph, Row, Table}; use ratatui::Frame; -use super::{btop_block, fmt_mem_kb, fmt_tokens, grad_at, make_gradient, truncate_str}; +use super::{btop_block_active, fmt_mem_kb, fmt_tokens, grad_at, make_gradient, truncate_str}; pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_sessions_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_sessions_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { // Render the outer block - let block = btop_block("sessions", "⁶", theme.proc_box, theme); + let block = btop_block_active("sessions", "⁶", theme.proc_box, theme, active); f.render_widget(block, area); let inner = Rect { @@ -36,6 +46,8 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & .sum(); let detail_reserve: u16 = if app.show_timeline { (inner.height * 2 / 3).min(inner.height.saturating_sub(5)) + } else if inner.height <= 12 { + 6.min(inner.height.saturating_sub(3)) } else { 10.min(inner.height / 2) }; @@ -69,20 +81,22 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & ); let mut rows = Vec::new(); - // Responsive columns — 9 core columns always visible, widths shrink at narrow terminals. - // Only Memory/Turn/Pid are hidden when truly narrow. + // Responsive columns: keep the identity, current task, status, and context + // visible first; add lower-value columns back as width allows. let w = inner.width; let show_pid = w >= 120; + let show_session_id = w >= 76; + let show_model = w >= 90; + let show_tokens = w >= 86; let show_memory = w >= 100; let show_turn = w >= 100; - // Responsive widths — all 9 core columns always visible, widths adapt let project_w: u16 = if w >= 120 { 14 - } else if w >= 100 { + } else if w >= 80 { 10 } else { - 7 + 8 }; let session_w: u16 = if w >= 110 { 9 } else { 5 }; let session_label = if w >= 110 { @@ -90,9 +104,15 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & } else { t("col.sess") }; - let status_w: u16 = if w >= 100 { 8 } else { 6 }; + let status_w: u16 = if w >= 100 { + 8 + } else if w >= 72 { + 6 + } else { + 3 + }; let model_w: u16 = if w >= 110 { 13 } else { 10 }; - let context_w: u16 = if w >= 100 { 7 } else { 5 }; + let context_w: u16 = if w >= 100 { 7 } else { 4 }; let context_label = if w >= 100 { t("col.context") } else { @@ -150,7 +170,6 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & let summary_col = app.session_summary(session); - // Build cells — 9 core columns always present, only Pid/Memory/Turn conditional let mut cells = vec![ Cell::from(Span::styled(marker, Style::default().fg(theme.hi_fg))), Cell::from(Span::styled(agent_label, Style::default().fg(agent_color))), @@ -161,40 +180,46 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & Style::default().fg(theme.inactive_fg), ))); } - cells.extend([ - Cell::from(Span::styled( - truncate_str(&session.project_name, project_w as usize), - Style::default().fg(theme.title), - )), - Cell::from(Span::styled( + cells.push(Cell::from(Span::styled( + truncate_str(&session.project_name, project_w as usize), + Style::default().fg(theme.title), + ))); + if show_session_id { + cells.push(Cell::from(Span::styled( truncate_str(sid_short, session_w as usize), Style::default().fg(theme.session_id), - )), + ))); + } + cells.extend([ Cell::from(Span::styled( - summary_col, + truncate_str(&summary_col, w.saturating_sub(24) as usize), Style::default().fg(theme.main_fg), )), Cell::from(Span::styled( truncate_str(&status_icon_str, status_w as usize), Style::default().fg(status_color), )), - Cell::from(Span::styled( + ]); + if show_model { + cells.push(Cell::from(Span::styled( truncate_str(&model_short, model_w as usize), Style::default().fg(if model_short == "-" { theme.inactive_fg } else { theme.graph_text }), - )), - Cell::from(Span::styled( - format!("{:.0}%", session.context_percent), - Style::default().fg(ctx_color), - )), - Cell::from(Span::styled( + ))); + } + cells.push(Cell::from(Span::styled( + format!("{:.0}%", session.context_percent), + Style::default().fg(ctx_color), + ))); + if show_tokens { + cells.push(Cell::from(Span::styled( fmt_tokens(session.total_tokens()), Style::default().fg(theme.main_fg), - )), - ]); + ))); + } if show_memory { cells.push(Cell::from(Span::styled( if session.mem_mb > 0 { @@ -215,8 +240,14 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & rows.push(Row::new(cells).style(row_style).height(1)); // 2nd line: task text in Summary column - let summary_idx = if show_pid { 5 } else { 4 }; - let total_cols = 9 + show_pid as usize + show_memory as usize + show_turn as usize; + let summary_idx = 3 + show_pid as usize + show_session_id as usize; + let total_cols = 6 + + show_pid as usize + + show_session_id as usize + + show_model as usize + + show_tokens as usize + + show_memory as usize + + show_turn as usize; let task_cells: Vec = (0..total_cols) .map(|j| { if j == summary_idx { @@ -258,21 +289,27 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & if show_pid { sa_cells.push(Cell::from("")); } + sa_cells.push(Cell::from(Span::styled( + truncate_str(&sa.name, project_w as usize), + Style::default().fg(theme.graph_text), + ))); + if show_session_id { + sa_cells.push(Cell::from("")); + } sa_cells.extend([ - Cell::from(Span::styled( - truncate_str(&sa.name, project_w as usize), - Style::default().fg(theme.graph_text), - )), - Cell::from(""), Cell::from(""), Cell::from(Span::styled(icon, Style::default().fg(sa_fg))), - Cell::from(""), - Cell::from(""), - Cell::from(Span::styled( + ]); + if show_model { + sa_cells.push(Cell::from("")); + } + sa_cells.push(Cell::from("")); + if show_tokens { + sa_cells.push(Cell::from(Span::styled( fmt_tokens(sa.tokens), Style::default().fg(theme.graph_text), - )), - ]); + ))); + } if show_memory { sa_cells.push(Cell::from("")); } @@ -294,15 +331,21 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & if show_pid { header_cells.push(Cell::from(Span::styled(t("col.pid"), header_style))); } + header_cells.push(Cell::from(Span::styled(t("col.project"), header_style))); + if show_session_id { + header_cells.push(Cell::from(Span::styled(session_label, header_style))); + } header_cells.extend([ - Cell::from(Span::styled(t("col.project"), header_style)), - Cell::from(Span::styled(session_label, header_style)), Cell::from(Span::styled(t("col.summary"), header_style)), Cell::from(Span::styled(t("col.status"), header_style)), - Cell::from(Span::styled(t("col.model"), header_style)), - Cell::from(Span::styled(context_label, header_style)), - Cell::from(Span::styled(t("col.tokens"), header_style)), ]); + if show_model { + header_cells.push(Cell::from(Span::styled(t("col.model"), header_style))); + } + header_cells.push(Cell::from(Span::styled(context_label, header_style))); + if show_tokens { + header_cells.push(Cell::from(Span::styled(t("col.tokens"), header_style))); + } if show_memory { header_cells.push(Cell::from(Span::styled(t("col.memory"), header_style))); } @@ -318,15 +361,19 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & if show_pid { widths_vec.push(Constraint::Length(6)); // pid } - widths_vec.extend([ - Constraint::Length(project_w), // project - Constraint::Length(session_w), // session id - Constraint::Fill(1), // summary (fills remaining) - Constraint::Length(status_w), // status - Constraint::Length(model_w), // model - Constraint::Length(context_w), // context - Constraint::Length(tokens_w), // tokens - ]); + widths_vec.push(Constraint::Length(project_w)); // project + if show_session_id { + widths_vec.push(Constraint::Length(session_w)); // session id + } + widths_vec.push(Constraint::Fill(1)); // summary (fills remaining) + widths_vec.push(Constraint::Length(status_w)); // status + if show_model { + widths_vec.push(Constraint::Length(model_w)); // model + } + widths_vec.push(Constraint::Length(context_w)); // context + if show_tokens { + widths_vec.push(Constraint::Length(tokens_w)); // tokens + } if show_memory { widths_vec.push(Constraint::Length(8)); // memory } @@ -457,20 +504,21 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & let has_children = !session.children.is_empty(); let has_subagents = !session.subagents.is_empty(); let has_tool_calls = !session.tool_calls.is_empty(); + let has_left_detail = has_children || has_subagents; let has_file_audit = app.show_file_audit && !session.file_accesses.is_empty(); // Focus mode: file audit (F) takes priority over timeline (L) when both // are toggled on. Only one "full lower" mode is active at a time. let file_audit_focused = has_file_audit; let timeline_focused = !file_audit_focused && app.show_timeline && has_tool_calls; - // Default split: when neither focus mode is active, show a compact - // timeline in the right half of the lower area - but only if the - // terminal is wide enough that both halves remain readable - // (draw_timeline reserves 42 cols for labels). + // Default timeline: split only when there is useful left-side detail; + // otherwise use the whole lower area. const TIMELINE_SPLIT_MIN_WIDTH: u16 = 120; - let timeline_side_by_side = !file_audit_focused + let timeline_default = !file_audit_focused && !app.show_timeline && has_tool_calls && detail_body.width >= TIMELINE_SPLIT_MIN_WIDTH; + let timeline_side_by_side = timeline_default && has_left_detail; + let timeline_full_width = timeline_default && !has_left_detail; // Always show SESSION header (task) at top, then children/subagents/timeline/file_audit below let session_header_h: u16 = { @@ -482,6 +530,7 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & }; let has_lower = file_audit_focused || timeline_focused + || timeline_full_width || timeline_side_by_side || has_children || has_subagents; @@ -498,12 +547,20 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & // SESSION header — always rendered { let mut lines = Vec::new(); + let sid_short = if session.session_id.len() >= 8 { + &session.session_id[..8] + } else { + &session.session_id + }; + let session_ref = if header_area.width <= 80 { + format!("►{} · {}", sid_short, session.project_name) + } else { + format!("►{} · {}", session.session_id, session.cwd) + }; lines.push(Line::from(Span::styled( - format!( - " {} (►{} · {})", - t("detail.session").as_str(), - &session.session_id, - &session.cwd + truncate_str( + &format!(" {} ({})", t("detail.session").as_str(), session_ref), + header_area.width as usize, ), Style::default() .fg(theme.title) @@ -528,18 +585,15 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & // Layout below the session header: // - file audit focus (F): full-width file audit // - timeline focus (L): full-width timeline - // - wide terminal with tool calls: left = children/subagents, right = compact timeline + // - wide terminal with left detail + tool calls: split lower area + // - wide terminal with only tool calls: full-width timeline // - otherwise: children/subagents only (or nothing) if let Some(lower) = lower_area { if file_audit_focused { draw_file_audit(f, session, lower, theme); - } else if timeline_focused { + } else if timeline_focused || timeline_full_width { draw_timeline(f, session, lower, theme, app.timeline_scroll); } else { - // Split 50/50 whenever the side-by-side timeline is active, even if - // there's no left content - consistent layout beats saving the empty - // half, and sessions that gain/lose children at runtime shouldn't - // make the timeline flicker between full- and half-width. let (left_area, right_timeline_area) = if timeline_side_by_side { let split = Layout::default() .direction(Direction::Horizontal) @@ -556,10 +610,23 @@ pub(crate) fn draw_sessions_panel(f: &mut Frame, app: &App, area: Rect, theme: & if has_children || has_subagents { let body_chunks = if has_children && has_subagents { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) - .split(left_area) + if left_area.width < 90 { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(left_area) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(55), + ]) + .split(left_area) + } } else { Layout::default() .direction(Direction::Horizontal) @@ -889,27 +956,6 @@ fn tool_label(name: &str) -> &str { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codex_exec_command_uses_bash_color() { - let theme = Theme::default(); - assert_eq!( - tool_color("exec_command", &theme), - tool_color("Bash", &theme) - ); - } - - #[test] - fn codex_tool_labels_fit_timeline_name_column() { - assert_eq!(tool_label("exec_command"), "Exec"); - assert_eq!(tool_label("update_plan"), "Plan"); - assert!(tool_label("exec_command").len() <= 6); - } -} - fn fmt_duration(ms: u64) -> String { if ms >= 60_000 { format!("{}m{:.0}s", ms / 60_000, (ms % 60_000) as f64 / 1000.0) @@ -1112,3 +1158,24 @@ fn draw_timeline( f.render_widget(Paragraph::new(lines), area); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_exec_command_uses_bash_color() { + let theme = Theme::default(); + assert_eq!( + tool_color("exec_command", &theme), + tool_color("Bash", &theme) + ); + } + + #[test] + fn codex_tool_labels_fit_timeline_name_column() { + assert_eq!(tool_label("exec_command"), "Exec"); + assert_eq!(tool_label("update_plan"), "Plan"); + assert!(tool_label("exec_command").len() <= 6); + } +} diff --git a/src/ui/tokens.rs b/src/ui/tokens.rs index abbfb06..b693b87 100644 --- a/src/ui/tokens.rs +++ b/src/ui/tokens.rs @@ -8,11 +8,21 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use super::{ - braille_sparkline, btop_block, fmt_tokens, grad_at, make_gradient, meter_bar, styled_label, - truncate_str, + braille_sparkline, btop_block_active, fmt_tokens, grad_at, make_gradient, meter_bar, + styled_label, truncate_str, }; pub(crate) fn draw_tokens_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_tokens_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_tokens_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { let selected = app.sessions.get(app.selected); let total_in: u64 = selected.map(|s| s.total_input_tokens).unwrap_or(0); let total_out: u64 = selected.map(|s| s.total_output_tokens).unwrap_or(0); @@ -162,6 +172,6 @@ pub(crate) fn draw_tokens_panel(f: &mut Frame, app: &App, area: Rect, theme: &Th } else { "tokens".to_string() }; - let block = btop_block(&panel_title, "³", theme.mem_box, theme); + let block = btop_block_active(&panel_title, "³", theme.mem_box, theme, active); f.render_widget(Paragraph::new(lines).block(block), area); } From b356eac26235990d7204184a6f9b9ba47fda8a42 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 7 May 2026 14:21:40 +1000 Subject: [PATCH 2/3] feat: show session chat history --- src/app.rs | 1 + src/collector/claude.rs | 124 ++++++++++++++++++++++++++++++++++++++-- src/collector/codex.rs | 78 ++++++++++++++++++++++--- src/demo.rs | 52 ++++++++++++++++- src/locale.rs | 2 + src/model/session.rs | 19 ++++++ src/ui/mod.rs | 27 +++++++++ src/ui/sessions.rs | 75 ++++++++++++++++++------ 8 files changed, 346 insertions(+), 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index d4d281c..f5b8f17 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1101,6 +1101,7 @@ mod tests { children: vec![], initial_prompt: String::new(), first_assistant_text: String::new(), + chat_messages: vec![], tool_calls: vec![], pending_since_ms: 0, thinking_since_ms: 0, diff --git a/src/collector/claude.rs b/src/collector/claude.rs index ab84b92..94c5af9 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -1,7 +1,7 @@ use super::process::{self, ProcInfo}; use crate::model::{ - AgentSession, ChildProcess, FileAccess, FileOp, SessionFile, SessionStatus, SubAgent, - MAX_FILE_ACCESSES, + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, SessionFile, + SessionStatus, SubAgent, MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, }; use serde_json::Value; use std::collections::HashMap; @@ -106,7 +106,8 @@ impl ClaudeCollector { } let self_pid = std::process::id(); - let active_session_paths = self.discover_active_session_paths(&shared.process_info, self_pid); + let active_session_paths = + self.discover_active_session_paths(&shared.process_info, self_pid); let active_config_dirs: Vec = active_session_paths .iter() .map(|(_, config)| config.clone()) @@ -408,6 +409,11 @@ impl ClaudeCollector { prev.tool_calls .extend(delta.tool_calls.into_iter().take(remaining)); } + prev.chat_messages.extend(delta.chat_messages); + let len = prev.chat_messages.len(); + if len > MAX_CHAT_MESSAGES { + prev.chat_messages.drain(..len - MAX_CHAT_MESSAGES); + } // Only overwrite turn-state when the delta actually // observed new user/assistant lines. A no-op tick (file // didn't grow) returns an empty delta whose zeroed @@ -458,6 +464,7 @@ impl ClaudeCollector { token_history: Vec::new(), initial_prompt: String::new(), first_assistant_text: String::new(), + chat_messages: Vec::new(), tool_calls: Vec::new(), last_assistant_ts_ms: 0, last_user_ts_ms: 0, @@ -485,6 +492,7 @@ impl ClaudeCollector { let compaction_count = cached.compaction_count; let initial_prompt = cached.initial_prompt.clone(); let first_assistant_text = cached.first_assistant_text.clone(); + let chat_messages = cached.chat_messages.clone(); let tool_calls = cached.tool_calls.clone(); let file_accesses = cached.file_accesses.clone(); @@ -623,6 +631,7 @@ impl ClaudeCollector { children, initial_prompt, first_assistant_text, + chat_messages, tool_calls, pending_since_ms: cached.last_assistant_ts_ms, thinking_since_ms: cached.last_user_ts_ms, @@ -1156,6 +1165,8 @@ struct TranscriptResult { initial_prompt: String, /// First assistant response text (text blocks only, no tool_use) first_assistant_text: String, + /// Recent real chat messages, excluding tool_result wrappers and tool inputs. + chat_messages: Vec, /// Tool call timeline extracted from transcript. tool_calls: Vec, /// Timestamp of the last assistant turn (epoch ms), used to compute tool duration. @@ -1238,6 +1249,7 @@ fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { token_history: Vec::new(), initial_prompt: String::new(), first_assistant_text: String::new(), + chat_messages: Vec::new(), tool_calls: Vec::new(), last_assistant_ts_ms: 0, last_user_ts_ms: 0, @@ -1419,6 +1431,14 @@ fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { } } } + let assistant_text = extract_chat_text(msg); + if !assistant_text.is_empty() { + push_chat_message( + &mut result.chat_messages, + ChatRole::Assistant, + assistant_text, + ); + } // Extract all tool_use entries: timeline + current_task + file access audit let mut has_tool_use = false; if let Some(content) = msg.get("content").and_then(|c| c.as_array()) @@ -1479,6 +1499,7 @@ fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { } } Some("user") => { + let is_tool_result = is_tool_result_user_msg(val.get("message")); // Compute tool call duration: time from assistant turn to this user turn if entry_ts_ms > 0 && result.last_assistant_ts_ms > 0 { let duration = @@ -1510,7 +1531,7 @@ fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { // inside one logical turn, and treating each // tool_result as the start of a new thinking window // makes the status flicker Think ↔ Wait per tool call. - if entry_ts_ms > 0 && !is_tool_result_user_msg(val.get("message")) { + if entry_ts_ms > 0 && !is_tool_result { result.last_user_ts_ms = entry_ts_ms; } result.saw_turn = true; @@ -1526,6 +1547,18 @@ fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { result.initial_prompt = extract_prompt_text(msg); } } + if !is_tool_result { + if let Some(msg) = val.get("message") { + let user_text = extract_chat_text(msg); + if !user_text.is_empty() { + push_chat_message( + &mut result.chat_messages, + ChatRole::User, + user_text, + ); + } + } + } } _ => {} } @@ -1572,6 +1605,64 @@ fn is_tool_result_user_msg(message: Option<&Value>) -> bool { .all(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")) } +fn push_chat_message(messages: &mut Vec, role: ChatRole, text: String) { + if text.is_empty() { + return; + } + messages.push(ChatMessage { role, text }); + let len = messages.len(); + if len > MAX_CHAT_MESSAGES { + messages.drain(..len - MAX_CHAT_MESSAGES); + } +} + +fn extract_chat_text(message: &Value) -> String { + let raw = match message.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|block| { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + block + .get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join(" "), + _ => String::new(), + }; + clean_chat_text(&raw, 500) +} + +fn clean_chat_text(raw: &str, max: usize) -> String { + let cleaned = raw + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with("```")) + .collect::>() + .join(" "); + + let mut without_images = cleaned; + while let Some(start) = without_images.find("[Image") { + if let Some(end) = without_images[start..].find(']') { + without_images = format!( + "{}{}", + &without_images[..start], + without_images[start + end + 1..].trim_start() + ); + } else { + break; + } + } + + let redacted = super::redact_secrets(without_images.trim()); + truncate(&redacted, max) +} + fn encode_cwd_path(cwd: &str) -> String { cwd.chars() .map(|c| match c { @@ -2513,6 +2604,31 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl assert_eq!(result.token_history.len(), 2); } + #[test] + fn test_parse_transcript_chat_tail_skips_tool_results() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"fix login"}}"#, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"I'll inspect auth."},{"type":"tool_use","name":"Read","input":{"file_path":"src/auth.rs"}}]}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:00:06Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"secret output"}]}}"#, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:10Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"Root cause is the guard."}]}}"#, + ], + ); + let result = parse_transcript(file.path(), 0); + assert_eq!(result.chat_messages.len(), 3); + assert_eq!(result.chat_messages[0].role, ChatRole::User); + assert_eq!(result.chat_messages[0].text, "fix login"); + assert_eq!(result.chat_messages[1].role, ChatRole::Assistant); + assert_eq!(result.chat_messages[1].text, "I'll inspect auth."); + assert_eq!(result.chat_messages[2].text, "Root cause is the guard."); + assert!(result + .chat_messages + .iter() + .all(|msg| !msg.text.contains("secret output"))); + } + #[test] fn test_parse_transcript_file_accesses_sliding_window() { // Regression: parse_transcript used to gate pushes on a `< MAX` diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 5a54834..6ee42f7 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1,5 +1,8 @@ use super::process::{self, ProcInfo}; -use crate::model::{AgentSession, ChildProcess, RateLimitInfo, SessionStatus, ToolCall}; +use crate::model::{ + AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, SessionStatus, ToolCall, + MAX_CHAT_MESSAGES, +}; use serde_json::Value; use std::collections::HashMap; use std::fs; @@ -47,10 +50,8 @@ impl CodexCollector { // Step 1: Find running codex processes from shared ps data (no extra ps call). // When MCP suppression is on, exclude `codex mcp-server` PIDs — those // are surfaced through the MCP servers panel instead. See issue #95. - let codex_pids = Self::find_codex_pids_from_shared( - &shared.process_info, - &shared.mcp_server_pids, - ); + let codex_pids = + Self::find_codex_pids_from_shared(&shared.process_info, &shared.mcp_server_pids); let just_pids: Vec = codex_pids.iter().map(|(p, _)| *p).collect(); let pid_to_jsonl = Self::map_pid_to_jsonl(&just_pids, &self.sessions_dir); let pid_is_exec: HashMap = codex_pids.into_iter().collect(); @@ -176,7 +177,9 @@ impl CodexCollector { let mem_mb = proc.map(|p| p.rss_kb / 1024).unwrap_or(0); let display_pid = pid.unwrap_or(0); - let project_name = process::last_path_segment(&result.cwd).unwrap_or("?").to_string(); + let project_name = process::last_path_segment(&result.cwd) + .unwrap_or("?") + .to_string(); // Status detection // Note: Codex interactive sessions emit task_complete after every turn, @@ -283,6 +286,7 @@ impl CodexCollector { children, initial_prompt: result.initial_prompt, first_assistant_text: String::new(), + chat_messages: result.chat_messages, tool_calls: result.tool_calls, pending_since_ms: result.pending_since_ms, thinking_since_ms: result.thinking_since_ms, @@ -452,6 +456,7 @@ struct CodexJSONLResult { model_generating: bool, last_activity: std::time::SystemTime, initial_prompt: String, + chat_messages: Vec, total_input: u64, total_output: u64, total_cache_read: u64, @@ -499,6 +504,28 @@ fn sanitize_tool_arg(arg: &str) -> String { redacted.chars().take(120).collect() } +fn push_chat_message(messages: &mut Vec, role: ChatRole, text: String) { + if text.is_empty() { + return; + } + messages.push(ChatMessage { role, text }); + let len = messages.len(); + if len > MAX_CHAT_MESSAGES { + messages.drain(..len - MAX_CHAT_MESSAGES); + } +} + +fn clean_chat_text(raw: &str, max: usize) -> String { + let cleaned = raw + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && !l.starts_with("```")) + .collect::>() + .join(" "); + let redacted = super::redact_secrets(&cleaned); + redacted.chars().take(max).collect() +} + fn parse_codex_tool_arg(arguments: &str) -> String { let Ok(value) = serde_json::from_str::(arguments) else { return String::new(); @@ -607,6 +634,7 @@ fn parse_codex_jsonl(path: &Path) -> Option { model_generating: false, last_activity: std::time::UNIX_EPOCH, initial_prompt: String::new(), + chat_messages: Vec::new(), total_input: 0, total_output: 0, total_cache_read: 0, @@ -699,11 +727,16 @@ fn parse_codex_jsonl(path: &Path) -> Option { Some("user_message") => { result.model_generating = true; result.thinking_since_ms = event_timestamp_ms(&val).unwrap_or(0); - if result.initial_prompt.is_empty() { - if let Some(msg) = payload["message"].as_str() { + if let Some(msg) = payload["message"].as_str() { + if result.initial_prompt.is_empty() { let truncated: String = msg.chars().take(120).collect(); result.initial_prompt = super::redact_secrets(&truncated); } + push_chat_message( + &mut result.chat_messages, + ChatRole::User, + clean_chat_text(msg, 500), + ); } } Some("token_count") => { @@ -776,6 +809,13 @@ fn parse_codex_jsonl(path: &Path) -> Option { result.turn_count += 1; result.model_generating = false; result.thinking_since_ms = 0; + if let Some(msg) = payload["message"].as_str() { + push_chat_message( + &mut result.chat_messages, + ChatRole::Assistant, + clean_chat_text(msg, 500), + ); + } } Some("task_complete") => { result.task_complete = true; @@ -1073,6 +1113,28 @@ mod tests { ); } + #[test] + fn test_parse_codex_chat_tail_from_user_and_agent_messages() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"user_message","message":"check auth sk-proj-secret"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:02:00Z","payload":{"type":"agent_message","message":"Auth guard is the failing path."}}"#, + ], + ); + let result = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(result.chat_messages.len(), 2); + assert_eq!(result.chat_messages[0].role, ChatRole::User); + assert_eq!(result.chat_messages[0].text, "check auth [REDACTED]"); + assert_eq!(result.chat_messages[1].role, ChatRole::Assistant); + assert_eq!( + result.chat_messages[1].text, + "Auth guard is the failing path." + ); + } + #[test] fn test_parse_codex_turn_context_effort() { let mut file = tempfile::NamedTempFile::new().unwrap(); diff --git a/src/demo.rs b/src/demo.rs index 288bea4..4fabfce 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::model::{ - AgentSession, ChildProcess, FileAccess, FileOp, OrphanPort, RateLimitInfo, SessionStatus, - SubAgent, ToolCall, + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, OrphanPort, + RateLimitInfo, SessionStatus, SubAgent, ToolCall, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -89,6 +89,24 @@ pub fn populate_demo(app: &mut App) { ], first_assistant_text: String::new(), + chat_messages: vec![ + ChatMessage { + role: ChatRole::User, + text: "Implement Stripe payment integration for checkout flow".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "I'll inspect checkout and config paths first, then wire the smallest payment boundary.".into(), + }, + ChatMessage { + role: ChatRole::User, + text: "Keep webhook handling minimal and make tests cover declined cards.".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "Payment code is in place; current pass is tightening test failures around webhook signatures.".into(), + }, + ], initial_prompt: "Implement Stripe payment integration for checkout flow".into(), tool_calls: vec![ ToolCall { @@ -273,6 +291,16 @@ pub fn populate_demo(app: &mut App) { children: vec![], first_assistant_text: String::new(), + chat_messages: vec![ + ChatMessage { + role: ChatRole::User, + text: "Add batch inference endpoint with GPU scheduling".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "Endpoint is implemented; waiting on your choice for queue priority behavior.".into(), + }, + ], initial_prompt: "Add batch inference endpoint with GPU scheduling".into(), tool_calls: vec![], pending_since_ms: 0, @@ -332,6 +360,16 @@ pub fn populate_demo(app: &mut App) { ], first_assistant_text: String::new(), + chat_messages: vec![ + ChatMessage { + role: ChatRole::User, + text: "Fix CORS headers and add rate limiting middleware".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "I'll patch middleware order, then run the API smoke tests.".into(), + }, + ], initial_prompt: "Fix CORS headers and add rate limiting middleware".into(), tool_calls: vec![ ToolCall { @@ -409,6 +447,16 @@ pub fn populate_demo(app: &mut App) { }], first_assistant_text: String::new(), + chat_messages: vec![ + ChatMessage { + role: ChatRole::User, + text: "Create interactive heatmap component with D3.js".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "Building the component now; next check is responsive canvas sizing.".into(), + }, + ], initial_prompt: "Create interactive heatmap component with D3.js".into(), tool_calls: vec![], pending_since_ms: 0, diff --git a/src/locale.rs b/src/locale.rs index d7b862d..895cb29 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -92,6 +92,7 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("detail.turns", "turns"); m.insert("detail.effort", "effort"); m.insert("detail.timeline", "TIMELINE"); + m.insert("detail.chat", "CHAT"); m.insert("detail.calls", "calls"); m.insert("detail.running", "running"); m.insert("detail.thinking", "thinking"); @@ -336,6 +337,7 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("detail.turns", "轮"); m.insert("detail.effort", "投入"); m.insert("detail.timeline", "时间线"); + m.insert("detail.chat", "聊天"); m.insert("detail.calls", "调用"); m.insert("detail.running", "运行中"); m.insert("detail.thinking", "思考中"); diff --git a/src/model/session.rs b/src/model/session.rs index 30c7bd9..840872c 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -105,6 +105,22 @@ pub struct ToolCall { pub duration_ms: u64, } +#[derive(Debug, Clone, PartialEq)] +pub enum ChatRole { + User, + Assistant, +} + +/// A compact, redacted chat line from the session transcript. +#[derive(Debug, Clone, PartialEq)] +pub struct ChatMessage { + pub role: ChatRole, + pub text: String, +} + +/// Maximum chat messages kept per session to bound memory and UI noise. +pub const MAX_CHAT_MESSAGES: usize = 12; + #[derive(Debug, Clone)] pub struct AgentSession { /// Which CLI tool this session belongs to: "claude", "codex", etc. @@ -148,6 +164,8 @@ pub struct AgentSession { pub initial_prompt: String, /// First assistant response text (text blocks only) — used as summary fallback pub first_assistant_text: String, + /// Recent user/assistant chat tail, excluding tool results and tool inputs. + pub chat_messages: Vec, /// Timeline of tool calls extracted from transcript. pub tool_calls: Vec, /// Unix-epoch ms of the assistant turn whose `tool_use` blocks are still @@ -265,6 +283,7 @@ mod tests { children: Vec::new(), initial_prompt: String::new(), first_assistant_text: String::new(), + chat_messages: Vec::new(), tool_calls: Vec::new(), pending_since_ms: 0, thinking_since_ms: 0, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab683a1..67f9481 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1301,6 +1301,7 @@ mod tests { crate::demo::populate_demo(&mut app); app.sessions[app.selected].children.clear(); app.sessions[app.selected].subagents.clear(); + app.toggle_timeline(); let backend = TestBackend::new(160, 40); let mut terminal = Terminal::new(backend).unwrap(); @@ -1316,6 +1317,32 @@ mod tests { ); } + #[test] + fn desktop_default_detail_shows_chat_instead_of_timeline() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + app.sessions[app.selected].children.clear(); + app.sessions[app.selected].subagents.clear(); + + let backend = TestBackend::new(160, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + + assert!( + text.contains("CHAT"), + "chat should render by default\n{text}" + ); + assert!( + text.contains("webhook signatures"), + "recent chat tail should render selected session messages\n{text}" + ); + assert!( + !text.contains("TIMELINE"), + "timeline should be opt-in via l toggle\n{text}" + ); + } + #[test] fn desktop_size_keeps_mid_panels() { let text = render_demo(120, 40); diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index 657543c..0cdf6fc 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -1,6 +1,6 @@ use crate::app::App; use crate::locale::t; -use crate::model::{AgentSession, FileOp}; +use crate::model::{AgentSession, ChatRole, FileOp}; use crate::theme::Theme; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -504,21 +504,20 @@ pub(crate) fn draw_sessions_panel_active( let has_children = !session.children.is_empty(); let has_subagents = !session.subagents.is_empty(); let has_tool_calls = !session.tool_calls.is_empty(); + let has_chat = !session.chat_messages.is_empty(); let has_left_detail = has_children || has_subagents; let has_file_audit = app.show_file_audit && !session.file_accesses.is_empty(); // Focus mode: file audit (F) takes priority over timeline (L) when both // are toggled on. Only one "full lower" mode is active at a time. let file_audit_focused = has_file_audit; let timeline_focused = !file_audit_focused && app.show_timeline && has_tool_calls; - // Default timeline: split only when there is useful left-side detail; - // otherwise use the whole lower area. - const TIMELINE_SPLIT_MIN_WIDTH: u16 = 120; - let timeline_default = !file_audit_focused - && !app.show_timeline - && has_tool_calls - && detail_body.width >= TIMELINE_SPLIT_MIN_WIDTH; - let timeline_side_by_side = timeline_default && has_left_detail; - let timeline_full_width = timeline_default && !has_left_detail; + // Default detail is chat. Split only when there is useful left-side + // detail and enough width; otherwise chat gets the full lower area. + const CHAT_SPLIT_MIN_WIDTH: u16 = 120; + let chat_default = !file_audit_focused && !app.show_timeline && has_chat; + let chat_side_by_side = + chat_default && has_left_detail && detail_body.width >= CHAT_SPLIT_MIN_WIDTH; + let chat_full_width = chat_default && !chat_side_by_side; // Always show SESSION header (task) at top, then children/subagents/timeline/file_audit below let session_header_h: u16 = { @@ -530,8 +529,8 @@ pub(crate) fn draw_sessions_panel_active( }; let has_lower = file_audit_focused || timeline_focused - || timeline_full_width - || timeline_side_by_side + || chat_full_width + || chat_side_by_side || has_children || has_subagents; let (header_area, lower_area) = if has_lower { @@ -585,16 +584,17 @@ pub(crate) fn draw_sessions_panel_active( // Layout below the session header: // - file audit focus (F): full-width file audit // - timeline focus (L): full-width timeline - // - wide terminal with left detail + tool calls: split lower area - // - wide terminal with only tool calls: full-width timeline + // - default chat: full-width, or split with children/subagents on wide terminals // - otherwise: children/subagents only (or nothing) if let Some(lower) = lower_area { if file_audit_focused { draw_file_audit(f, session, lower, theme); - } else if timeline_focused || timeline_full_width { + } else if timeline_focused { draw_timeline(f, session, lower, theme, app.timeline_scroll); + } else if chat_full_width { + draw_chat_history(f, session, lower, theme); } else { - let (left_area, right_timeline_area) = if timeline_side_by_side { + let (left_area, right_chat_area) = if chat_side_by_side { let split = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) @@ -604,8 +604,8 @@ pub(crate) fn draw_sessions_panel_active( (lower, None) }; - if let Some(tl_area) = right_timeline_area { - draw_timeline(f, session, tl_area, theme, app.timeline_scroll); + if let Some(chat_area) = right_chat_area { + draw_chat_history(f, session, chat_area, theme); } if has_children || has_subagents { @@ -852,6 +852,45 @@ pub(crate) fn draw_sessions_panel_active( } } +/// Render the recent user/assistant chat tail for the selected session. +fn draw_chat_history(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { + if session.chat_messages.is_empty() { + return; + } + + let mut lines = Vec::new(); + lines.push(Line::from(Span::styled( + format!( + " {} ({})", + t("detail.chat").as_str(), + session.chat_messages.len() + ), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + ))); + + let visible_rows = area.height.saturating_sub(1) as usize; + let start = session.chat_messages.len().saturating_sub(visible_rows); + let text_w = (area.width as usize).saturating_sub(6); + + for msg in session.chat_messages.iter().skip(start) { + let (label, color) = match msg.role { + ChatRole::User => ("U", theme.hi_fg), + ChatRole::Assistant => ("A", theme.proc_misc), + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", label), Style::default().fg(color)), + Span::styled( + truncate_str(&msg.text, text_w), + Style::default().fg(theme.main_fg), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), area); +} + /// Render the file access audit log in the given area. fn draw_file_audit(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { use std::collections::HashSet; From b976a3a90b65c6ec3c69f52cf8cc8d0d832cdfa7 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 7 May 2026 14:27:32 +1000 Subject: [PATCH 3/3] fix: correct codex context usage --- src/collector/codex.rs | 46 +++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 6ee42f7..de8a537 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -457,6 +457,8 @@ struct CodexJSONLResult { last_activity: std::time::SystemTime, initial_prompt: String, chat_messages: Vec, + /// Input tokens excluding cached input, matching AgentSession's additive + /// token accounting where cache reads are stored separately. total_input: u64, total_output: u64, total_cache_read: u64, @@ -741,7 +743,9 @@ fn parse_codex_jsonl(path: &Path) -> Option { } Some("token_count") => { let info = &payload["info"]; - // Use total_token_usage as cumulative snapshot for totals + // Codex input_tokens already includes cached_input_tokens. + // Store only the non-cached input portion so + // AgentSession::total_tokens() does not double-count cache. let total = &info["total_token_usage"]; if total.is_object() { let inp = total["input_tokens"].as_u64().unwrap_or(0); @@ -750,22 +754,20 @@ fn parse_codex_jsonl(path: &Path) -> Option { .as_u64() .or_else(|| total["cache_read_input_tokens"].as_u64()) .unwrap_or(0); - result.total_input = inp; + result.total_input = inp.saturating_sub(cache); result.total_output = out; result.total_cache_read = cache; } - // Use last_token_usage for context % and sparkline + // Use last_token_usage input as the current context window. + // cached_input_tokens is a subset of input_tokens, not extra + // context after compaction. let last = &info["last_token_usage"]; if last.is_object() { let inp = last["input_tokens"].as_u64().unwrap_or(0); let out = last["output_tokens"].as_u64().unwrap_or(0); - let cache = last["cached_input_tokens"] - .as_u64() - .or_else(|| last["cache_read_input_tokens"].as_u64()) - .unwrap_or(0); - result.last_context_tokens = inp + cache; + result.last_context_tokens = inp; if result.token_history.len() < 10_000 { - result.token_history.push(inp + out + cache); + result.token_history.push(inp + out); } } // Context window may also appear inside info @@ -1010,17 +1012,33 @@ mod tests { &mut file, &[ SESSION_META, - r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"output_tokens":200,"cached_input_tokens":100},"last_token_usage":{"input_tokens":50,"output_tokens":20,"cached_input_tokens":10},"model_context_window":128000}}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"output_tokens":200,"cached_input_tokens":100,"total_tokens":700},"last_token_usage":{"input_tokens":50,"output_tokens":20,"cached_input_tokens":10,"total_tokens":70},"model_context_window":128000}}}"#, ], ); let result = parse_codex_jsonl(file.path()).unwrap(); - assert_eq!(result.total_input, 500); + assert_eq!(result.total_input, 400); assert_eq!(result.total_output, 200); assert_eq!(result.total_cache_read, 100); - assert_eq!(result.last_context_tokens, 60); // 50 + 10 + assert_eq!(result.last_context_tokens, 50); assert_eq!(result.context_window, 128000); assert_eq!(result.token_history.len(), 1); - assert_eq!(result.token_history[0], 80); // 50 + 20 + 10 + assert_eq!(result.token_history[0], 70); + } + + #[test] + fn test_parse_codex_context_does_not_double_count_cached_input() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":58140501,"cached_input_tokens":55267712,"output_tokens":114278,"total_tokens":58254779},"last_token_usage":{"input_tokens":151839,"cached_input_tokens":146816,"output_tokens":621,"total_tokens":152460},"model_context_window":258400}}}"#, + ], + ); + let result = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(result.last_context_tokens, 151_839); + assert_eq!(result.context_window, 258_400); + assert!(result.last_context_tokens < result.context_window); } #[test] @@ -1052,7 +1070,7 @@ mod tests { ); let result = parse_codex_jsonl(file.path()).unwrap(); assert_eq!(result.total_cache_read, 30); - assert_eq!(result.last_context_tokens, 25); // 20 + 5 + assert_eq!(result.last_context_tokens, 20); } #[test]