Three-pane subconscious debug screen with shared widgets

New layout for F3 screen:
- Top-left: agent list using ratatui List widget with ListState
- Middle-left: expandable agent state (persistent across runs)
- Bottom-left: memory store activity by provenance, walked keys
- Right: context tree from fork point, reusing SectionTree

Tab/Shift-Tab cycles focus clockwise between panes; focused pane
gets white border. Each pane handles its own input when focused.

Extracted user/widgets.rs:
- SectionTree (moved from mod.rs): expand/collapse tree for ContextSection
- pane_block_focused(): standard bordered block with focus indicator
- format_age()/format_ts_age(): shared duration formatting

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 19:03:14 -04:00
parent edfa1c37f5
commit 818cdcc4e5
4 changed files with 399 additions and 288 deletions

View file

@ -1,120 +1,35 @@
// context_screen.rs — F2 context/debug overlay // context_screen.rs — F2 context/debug overlay
// //
// Full-screen overlay showing model info, context window breakdown, // Full-screen overlay showing model info, context window breakdown,
// and runtime state. Supports tree navigation with expand/collapse. // and runtime state. Uses SectionTree for the expand/collapse tree.
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Style},
text::Line, text::Line,
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Paragraph, Wrap},
Frame, Frame,
crossterm::event::KeyCode,
}; };
use super::{App, ScreenView, screen_legend}; use super::{App, ScreenView, screen_legend};
use crate::agent::context::ContextSection; use super::widgets::{SectionTree, pane_block};
pub(crate) struct ConsciousScreen { pub(crate) struct ConsciousScreen {
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>, agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
scroll: u16, tree: SectionTree,
selected: Option<usize>,
expanded: std::collections::HashSet<usize>,
} }
impl ConsciousScreen { impl ConsciousScreen {
pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self { pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self {
Self { agent, scroll: 0, selected: None, expanded: std::collections::HashSet::new() } Self { agent, tree: SectionTree::new() }
} }
fn read_context_state(&self) -> Vec<ContextSection> { fn read_context_state(&self) -> Vec<crate::agent::context::ContextSection> {
match self.agent.try_lock() { match self.agent.try_lock() {
Ok(ag) => ag.context_state_summary(), Ok(ag) => ag.context_state_summary(),
Err(_) => Vec::new(), Err(_) => Vec::new(),
} }
} }
fn item_count(&self, context_state: &[ContextSection]) -> usize {
fn count_section(section: &ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
let my_idx = *idx;
*idx += 1;
let mut total = 1;
if expanded.contains(&my_idx) {
for child in &section.children {
total += count_section(child, expanded, idx);
}
}
total
}
let mut idx = 0;
let mut total = 0;
for section in context_state {
total += count_section(section, &self.expanded, &mut idx);
}
total
}
fn scroll_to_selected(&mut self, _item_count: usize) {
let header_lines = 8u16;
if let Some(sel) = self.selected {
let sel_line = header_lines + sel as u16;
if sel_line < self.scroll + 2 {
self.scroll = sel_line.saturating_sub(2);
} else if sel_line > self.scroll + 30 {
self.scroll = sel_line.saturating_sub(15);
}
}
}
fn render_section(
&self,
section: &ContextSection,
depth: usize,
lines: &mut Vec<Line>,
idx: &mut usize,
) {
let my_idx = *idx;
let selected = self.selected == Some(my_idx);
let expanded = self.expanded.contains(&my_idx);
let has_children = !section.children.is_empty();
let has_content = !section.content.is_empty();
let expandable = has_children || has_content;
let indent = " ".repeat(depth + 1);
let marker = if !expandable { " " } else if expanded { "" } else { "" };
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
let style = if selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::styled(label, style));
*idx += 1;
if expanded {
if has_children {
for child in &section.children {
self.render_section(child, depth + 1, lines, idx);
}
} else if has_content {
let content_indent = format!("{}", " ".repeat(depth + 1));
let content_lines: Vec<&str> = section.content.lines().collect();
let show = content_lines.len().min(50);
for line in &content_lines[..show] {
lines.push(Line::styled(
format!("{}{}", content_indent, line),
Style::default().fg(Color::DarkGray),
));
}
if content_lines.len() > 50 {
lines.push(Line::styled(
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
Style::default().fg(Color::DarkGray),
));
}
}
}
}
} }
impl ScreenView for ConsciousScreen { impl ScreenView for ConsciousScreen {
@ -126,32 +41,7 @@ impl ScreenView for ConsciousScreen {
if let ratatui::crossterm::event::Event::Key(key) = event { if let ratatui::crossterm::event::Event::Key(key) = event {
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
let context_state = self.read_context_state(); let context_state = self.read_context_state();
let item_count = self.item_count(&context_state); self.tree.handle_nav(key.code, &context_state);
match key.code {
KeyCode::Up => {
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
self.scroll_to_selected(item_count);
}
KeyCode::Down => {
let max = item_count.saturating_sub(1);
self.selected = Some(self.selected.map_or(0, |s| (s + 1).min(max)));
self.scroll_to_selected(item_count);
}
KeyCode::Right | KeyCode::Enter => {
if let Some(sel) = self.selected {
self.expanded.insert(sel);
}
}
KeyCode::Left => {
if let Some(sel) = self.selected {
self.expanded.remove(&sel);
}
}
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
_ => {}
}
} }
} }
@ -185,10 +75,7 @@ impl ScreenView for ConsciousScreen {
)); ));
lines.push(Line::raw("")); lines.push(Line::raw(""));
let mut flat_idx = 0usize; self.tree.render_sections(&context_state, &mut lines);
for section in &context_state {
self.render_section(section, 0, &mut lines, &mut flat_idx);
}
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────"))); lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────")));
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total))); lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total)));
@ -207,16 +94,13 @@ impl ScreenView for ConsciousScreen {
lines.push(Line::raw(format!(" Running processes: {}", app.running_processes))); lines.push(Line::raw(format!(" Running processes: {}", app.running_processes)));
lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len()))); lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len())));
let block = Block::default() let block = pane_block("context")
.title_top(Line::from(screen_legend()).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned());
.title_top(Line::from(" context ").right_aligned())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(block)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.scroll, 0)); .scroll((self.tree.scroll, 0));
frame.render_widget(para, area); frame.render_widget(para, area);
} }

View file

@ -8,6 +8,7 @@ mod context;
mod subconscious; mod subconscious;
mod unconscious; mod unconscious;
mod thalamus; mod thalamus;
mod widgets;
use anyhow::Result; use anyhow::Result;
use std::io::Write; use std::io::Write;

View file

@ -1,27 +1,52 @@
// subconscious_screen.rs — F3 subconscious agent overlay // subconscious_screen.rs — F3 subconscious agent overlay
//
// Three-pane layout:
// Top-left: Agent list (↑/↓ select)
// Bottom-left: Detail — outputs from selected agent's last run
// Right: Context tree from fork point (→/Enter expand, ← collapse)
use ratatui::{ use ratatui::{
layout::Rect, layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{List, ListItem, ListState, Paragraph, Wrap},
Frame, Frame,
crossterm::event::KeyCode, crossterm::event::KeyCode,
}; };
use super::{App, ScreenView, screen_legend}; use super::{App, ScreenView, screen_legend};
use crate::agent::context::ConversationEntry; use super::widgets::{SectionTree, pane_block_focused, format_age, format_ts_age};
use crate::agent::api::Role; use crate::agent::context::ContextSection;
#[derive(Clone, Copy, PartialEq)]
enum Pane { Agents, Outputs, History, Context }
// Clockwise: top-left → right → bottom-left → middle-left
const PANE_ORDER: &[Pane] = &[Pane::Agents, Pane::Context, Pane::History, Pane::Outputs];
pub(crate) struct SubconsciousScreen { pub(crate) struct SubconsciousScreen {
selected: usize, focus: Pane,
detail: bool, list_state: ListState,
scroll: u16, output_tree: SectionTree,
context_tree: SectionTree,
history_scroll: u16,
} }
impl SubconsciousScreen { impl SubconsciousScreen {
pub fn new() -> Self { pub fn new() -> Self {
Self { selected: 0, detail: false, scroll: 0 } let mut list_state = ListState::default();
list_state.select(Some(0));
Self {
focus: Pane::Agents,
list_state,
output_tree: SectionTree::new(),
context_tree: SectionTree::new(),
history_scroll: 0,
}
}
fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
} }
} }
@ -30,206 +55,232 @@ impl ScreenView for SubconsciousScreen {
fn tick(&mut self, frame: &mut Frame, area: Rect, fn tick(&mut self, frame: &mut Frame, area: Rect,
events: &[ratatui::crossterm::event::Event], app: &mut App) { events: &[ratatui::crossterm::event::Event], app: &mut App) {
let context_sections = self.read_sections(app);
let output_sections = self.output_sections(app);
for event in events { for event in events {
if let ratatui::crossterm::event::Event::Key(key) = event { if let ratatui::crossterm::event::Event::Key(key) = event {
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
match key.code { match key.code {
KeyCode::Up if !self.detail => { KeyCode::Tab => {
self.selected = self.selected.saturating_sub(1); let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
self.focus = PANE_ORDER[(idx + 1) % PANE_ORDER.len()];
} }
KeyCode::Down if !self.detail => { KeyCode::BackTab => {
self.selected = (self.selected + 1) let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
.min(app.agent_state.len().saturating_sub(1)); self.focus = PANE_ORDER[(idx + PANE_ORDER.len() - 1) % PANE_ORDER.len()];
} }
KeyCode::Enter | KeyCode::Right if !self.detail => { code => match self.focus {
self.detail = true; Pane::Agents => match code {
self.scroll = 0; KeyCode::Up => {
self.list_state.select_previous();
self.reset_pane_state();
} }
KeyCode::Esc | KeyCode::Left if self.detail => { KeyCode::Down => {
self.detail = false; self.list_state.select_next();
self.reset_pane_state();
} }
KeyCode::Up if self.detail => {
self.scroll = self.scroll.saturating_sub(3);
}
KeyCode::Down if self.detail => {
self.scroll += 3;
}
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
_ => {} _ => {}
} }
Pane::Outputs => self.output_tree.handle_nav(code, &output_sections),
Pane::History => match code {
KeyCode::Up => self.history_scroll = self.history_scroll.saturating_sub(3),
KeyCode::Down => self.history_scroll += 3,
KeyCode::PageUp => self.history_scroll = self.history_scroll.saturating_sub(20),
KeyCode::PageDown => self.history_scroll += 20,
_ => {}
}
Pane::Context => self.context_tree.handle_nav(code, &context_sections),
}
}
} }
} }
if self.detail { // Layout: left column (38%) | right column (62%)
self.draw_detail(frame, area, app); let [left, right] = Layout::horizontal([
} else { Constraint::Percentage(38),
self.draw_list(frame, area, app); Constraint::Percentage(62),
} ]).areas(area);
// Left column: agent list (top) | outputs (middle) | history (bottom, main)
let agent_count = app.agent_state.len().max(1) as u16;
let list_height = (agent_count + 2).min(left.height / 4);
let output_lines = app.agent_state.get(self.selected())
.map(|s| s.state.values().map(|v| v.lines().count() + 1).sum::<usize>())
.unwrap_or(0);
let output_height = (output_lines as u16 + 2).min(left.height / 4).max(3);
let [list_area, output_area, history_area] = Layout::vertical([
Constraint::Length(list_height),
Constraint::Length(output_height),
Constraint::Min(5),
]).areas(left);
self.draw_list(frame, list_area, app);
self.draw_outputs(frame, output_area, app);
self.draw_history(frame, history_area, app);
self.draw_context(frame, right, &context_sections, app);
} }
} }
impl SubconsciousScreen { impl SubconsciousScreen {
fn draw_list(&self, frame: &mut Frame, area: Rect, app: &App) { fn reset_pane_state(&mut self) {
let mut lines: Vec<Line> = Vec::new(); self.output_tree = SectionTree::new();
let section = Style::default().fg(Color::Yellow); self.context_tree = SectionTree::new();
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); self.history_scroll = 0;
lines.push(Line::raw(""));
let walked = app.walked_count;
lines.push(Line::styled(
format!("── Subconscious Agents ── walked: {}", walked), section));
lines.push(Line::styled(" (↑/↓ select, Enter view log)", hint));
lines.push(Line::raw(""));
if app.agent_state.is_empty() {
lines.push(Line::styled(" (no agents loaded)", hint));
} }
for (i, snap) in app.agent_state.iter().enumerate() { fn output_sections(&self, app: &App) -> Vec<ContextSection> {
let selected = i == self.selected; let snap = match app.agent_state.get(self.selected()) {
let prefix = if selected { "" } else { " " }; Some(s) => s,
let bg = if selected { None => return Vec::new(),
Style::default().bg(Color::DarkGray)
} else {
Style::default()
}; };
snap.state.iter().map(|(key, val)| {
ContextSection {
name: key.clone(),
tokens: 0,
content: val.clone(),
children: Vec::new(),
}
}).collect()
}
let status_spans = if snap.running { fn read_sections(&self, app: &App) -> Vec<ContextSection> {
vec![ let snap = match app.agent_state.get(self.selected()) {
Some(s) => s,
None => return Vec::new(),
};
snap.forked_agent.as_ref()
.and_then(|agent| agent.try_lock().ok())
.map(|ag| ag.conversation_sections_from(snap.fork_point))
.unwrap_or_default()
}
fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app.agent_state.iter().map(|snap| {
if snap.running {
ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(Color::Green)),
Span::styled("", Style::default().fg(Color::Green)),
Span::styled( Span::styled(
format!("{}{:<30}", prefix, snap.name), format!("p:{} t:{}", snap.current_phase, snap.turn),
bg.fg(Color::Green), Style::default().fg(Color::DarkGray),
), ),
Span::styled("", bg.fg(Color::Green)), ]))
Span::styled(
format!("phase: {} turn: {}", snap.current_phase, snap.turn),
bg,
),
]
} else { } else {
let ago = snap.last_run_secs_ago let ago = snap.last_run_secs_ago
.map(|s| { .map(|s| format_age(s))
if s < 60.0 { format!("{:.0}s ago", s) } .unwrap_or_else(|| "".to_string());
else if s < 3600.0 { format!("{:.0}m ago", s / 60.0) }
else { format!("{:.1}h ago", s / 3600.0) }
})
.unwrap_or_else(|| "never".to_string());
let entries = snap.forked_agent.as_ref() let entries = snap.forked_agent.as_ref()
.and_then(|a| a.try_lock().ok()) .and_then(|a| a.try_lock().ok())
.map(|ag| ag.context.entries.len().saturating_sub(snap.fork_point)) .map(|ag| ag.context.entries.len().saturating_sub(snap.fork_point))
.unwrap_or(0); .unwrap_or(0);
vec![ ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(Color::Gray)),
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled( Span::styled(
format!("{}{:<30}", prefix, snap.name), format!("{} {}e", ago, entries),
bg.fg(Color::Gray), Style::default().fg(Color::DarkGray),
), ),
Span::styled("", bg.fg(Color::DarkGray)), ]))
Span::styled( }
format!("idle last: {} entries: {}", }).collect();
ago, entries),
bg.fg(Color::DarkGray), let list = List::new(items)
), .block(pane_block_focused("agents", self.focus == Pane::Agents)
] .title_top(Line::from(screen_legend()).left_aligned()))
}; .highlight_symbol("")
lines.push(Line::from(status_spans)); .highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(list, area, &mut self.list_state);
} }
let block = Block::default() fn draw_outputs(&self, frame: &mut Frame, area: Rect, app: &App) {
.title_top(Line::from(screen_legend()).left_aligned()) let sections = self.output_sections(app);
.title_top(Line::from(" subconscious ").right_aligned()) let mut lines: Vec<Line> = Vec::new();
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); if sections.is_empty() {
let dim = Style::default().fg(Color::DarkGray);
let snap = app.agent_state.get(self.selected());
let msg = if snap.is_some_and(|s| s.running) { "(running...)" } else { "" };
lines.push(Line::styled(format!(" {}", msg), dim));
} else {
self.output_tree.render_sections(&sections, &mut lines);
}
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(pane_block_focused("state", self.focus == Pane::Outputs))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.scroll, 0)); .scroll((self.output_tree.scroll, 0));
frame.render_widget(para, area); frame.render_widget(para, area);
} }
fn draw_detail(&self, frame: &mut Frame, area: Rect, app: &App) { fn draw_history(&self, frame: &mut Frame, area: Rect, app: &App) {
let snap = match app.agent_state.get(self.selected) { let dim = Style::default().fg(Color::DarkGray);
Some(s) => s, let key_style = Style::default().fg(Color::Yellow);
None => return,
};
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow); let mut title = "memory store activity".to_string();
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
if let Some(snap) = app.agent_state.get(self.selected()) {
let short_name = snap.name.strip_prefix("subconscious-").unwrap_or(&snap.name);
title = format!("{} store activity", short_name);
if snap.history.is_empty() {
lines.push(Line::styled(" (no store activity)", dim));
} else {
for (key, ts) in &snap.history {
lines.push(Line::from(vec![
Span::styled(format!(" {:>6} ", format_ts_age(*ts)), dim),
Span::styled(key.as_str(), key_style),
]));
}
}
if !snap.walked.is_empty() {
lines.push(Line::raw("")); lines.push(Line::raw(""));
lines.push(Line::styled(format!("── {} ──", snap.name), section));
lines.push(Line::styled(" (Esc/← back, ↑/↓/PgUp/PgDn scroll)", hint));
lines.push(Line::raw(""));
// Read entries from the forked agent (from fork point onward)
let entries: Vec<ConversationEntry> = snap.forked_agent.as_ref()
.and_then(|agent| agent.try_lock().ok())
.map(|ag| ag.context.entries.get(snap.fork_point..).unwrap_or(&[]).to_vec())
.unwrap_or_default();
if entries.is_empty() {
lines.push(Line::styled(" (no run data)", hint));
}
for entry in &entries {
if entry.is_log() {
if let ConversationEntry::Log(text) = entry {
lines.push(Line::styled( lines.push(Line::styled(
format!(" [log] {}", text), format!(" walked ({}):", snap.walked.len()),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::Cyan),
));
}
continue;
}
let msg = entry.message();
let (role_str, role_color) = match msg.role {
Role::User => ("user", Color::Cyan),
Role::Assistant => ("assistant", Color::Reset),
Role::Tool => ("tool", Color::DarkGray),
Role::System => ("system", Color::Yellow),
};
let text = msg.content_text();
let tool_info = msg.tool_calls.as_ref().map(|tc| {
tc.iter().map(|c| c.function.name.as_str())
.collect::<Vec<_>>().join(", ")
});
let header = match &tool_info {
Some(tools) => format!(" [{}{}]", role_str, tools),
None => format!(" [{}]", role_str),
};
lines.push(Line::styled(header, Style::default().fg(role_color)));
if !text.is_empty() {
for line in text.lines().take(20) {
lines.push(Line::styled(
format!(" {}", line),
Style::default().fg(Color::Gray),
));
}
if text.lines().count() > 20 {
lines.push(Line::styled(
format!(" ... ({} more lines)", text.lines().count() - 20),
hint,
)); ));
for key in &snap.walked {
lines.push(Line::styled(format!(" {}", key), dim));
} }
} }
} }
let block = Block::default()
.title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(format!(" {} ", snap.name)).right_aligned())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(pane_block_focused(&title, self.focus == Pane::History))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.scroll, 0)); .scroll((self.history_scroll, 0));
frame.render_widget(para, area);
}
fn draw_context(
&self,
frame: &mut Frame,
area: Rect,
sections: &[ContextSection],
app: &App,
) {
let mut lines: Vec<Line> = Vec::new();
if sections.is_empty() {
lines.push(Line::styled(
" (no conversation data)",
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
));
} else {
self.context_tree.render_sections(sections, &mut lines);
}
let title = app.agent_state.get(self.selected())
.map(|s| s.name.as_str())
.unwrap_or("");
let para = Paragraph::new(lines)
.block(pane_block_focused(title, self.focus == Pane::Context))
.wrap(Wrap { trim: false })
.scroll((self.context_tree.scroll, 0));
frame.render_widget(para, area); frame.render_widget(para, area);
} }
} }

175
src/user/widgets.rs Normal file
View file

@ -0,0 +1,175 @@
// widgets.rs — Shared TUI helpers and reusable components
use ratatui::{
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders},
crossterm::event::KeyCode,
};
use crate::agent::context::ContextSection;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Standard pane block — cyan border, right-aligned title.
pub fn pane_block(title: &str) -> Block<'_> {
Block::default()
.title_top(Line::from(format!(" {} ", title)).right_aligned())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
}
/// Focused pane block — brighter border to indicate focus.
pub fn pane_block_focused(title: &str, focused: bool) -> Block<'_> {
let color = if focused { Color::White } else { Color::Cyan };
Block::default()
.title_top(Line::from(format!(" {} ", title)).right_aligned())
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
}
/// Format a duration in seconds as a compact human-readable string.
pub fn format_age(secs: f64) -> String {
if secs < 60.0 { format!("{:.0}s", secs) }
else if secs < 3600.0 { format!("{:.0}m", secs / 60.0) }
else if secs < 86400.0 { format!("{:.1}h", secs / 3600.0) }
else { format!("{:.0}d", secs / 86400.0) }
}
/// Format a unix epoch timestamp as age from now.
pub fn format_ts_age(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
format_age((now - ts).max(0) as f64)
}
// ---------------------------------------------------------------------------
// SectionTree — expand/collapse tree renderer for ContextSection
// ---------------------------------------------------------------------------
pub struct SectionTree {
pub selected: Option<usize>,
pub expanded: std::collections::HashSet<usize>,
pub scroll: u16,
}
impl SectionTree {
pub fn new() -> Self {
Self { selected: None, expanded: std::collections::HashSet::new(), scroll: 0 }
}
pub fn item_count(&self, sections: &[ContextSection]) -> usize {
fn count(section: &ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
let my_idx = *idx;
*idx += 1;
let mut total = 1;
if expanded.contains(&my_idx) {
for child in &section.children {
total += count(child, expanded, idx);
}
}
total
}
let mut idx = 0;
sections.iter().map(|s| count(s, &self.expanded, &mut idx)).sum()
}
pub fn handle_nav(&mut self, code: KeyCode, sections: &[ContextSection]) {
let item_count = self.item_count(sections);
match code {
KeyCode::Up => {
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
self.scroll_to_selected();
}
KeyCode::Down => {
let max = item_count.saturating_sub(1);
self.selected = Some(self.selected.map_or(0, |s| (s + 1).min(max)));
self.scroll_to_selected();
}
KeyCode::Right | KeyCode::Enter => {
if let Some(sel) = self.selected {
self.expanded.insert(sel);
}
}
KeyCode::Left => {
if let Some(sel) = self.selected {
self.expanded.remove(&sel);
}
}
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
_ => {}
}
}
fn scroll_to_selected(&mut self) {
if let Some(sel) = self.selected {
let sel_line = sel as u16;
if sel_line < self.scroll + 2 {
self.scroll = sel_line.saturating_sub(2);
} else if sel_line > self.scroll + 30 {
self.scroll = sel_line.saturating_sub(15);
}
}
}
pub fn render_sections(&self, sections: &[ContextSection], lines: &mut Vec<Line>) {
let mut idx = 0;
for section in sections {
self.render_one(section, 0, lines, &mut idx);
}
}
fn render_one(
&self,
section: &ContextSection,
depth: usize,
lines: &mut Vec<Line>,
idx: &mut usize,
) {
let my_idx = *idx;
let selected = self.selected == Some(my_idx);
let expanded = self.expanded.contains(&my_idx);
let has_children = !section.children.is_empty();
let has_content = !section.content.is_empty();
let expandable = has_children || has_content;
let indent = " ".repeat(depth + 1);
let marker = if !expandable { " " } else if expanded { "" } else { "" };
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
let style = if selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::styled(label, style));
*idx += 1;
if expanded {
if has_children {
for child in &section.children {
self.render_one(child, depth + 1, lines, idx);
}
} else if has_content {
let content_indent = format!("{}", " ".repeat(depth + 1));
let content_lines: Vec<&str> = section.content.lines().collect();
let show = content_lines.len().min(50);
for line in &content_lines[..show] {
lines.push(Line::styled(
format!("{}{}", content_indent, line),
Style::default().fg(Color::DarkGray),
));
}
if content_lines.len() > 50 {
lines.push(Line::styled(
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
Style::default().fg(Color::DarkGray),
));
}
}
}
}
}