From 818cdcc4e553489b882fe7fd2f6c85fc63c1717b Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Tue, 7 Apr 2026 19:03:14 -0400 Subject: [PATCH] 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 --- src/user/context.rs | 140 ++------------- src/user/mod.rs | 1 + src/user/subconscious.rs | 371 ++++++++++++++++++++++----------------- src/user/widgets.rs | 175 ++++++++++++++++++ 4 files changed, 399 insertions(+), 288 deletions(-) create mode 100644 src/user/widgets.rs diff --git a/src/user/context.rs b/src/user/context.rs index 5d72df2..a0203b2 100644 --- a/src/user/context.rs +++ b/src/user/context.rs @@ -1,120 +1,35 @@ // context_screen.rs — F2 context/debug overlay // // 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::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Color, Style}, text::Line, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Paragraph, Wrap}, Frame, - crossterm::event::KeyCode, }; use super::{App, ScreenView, screen_legend}; -use crate::agent::context::ContextSection; +use super::widgets::{SectionTree, pane_block}; pub(crate) struct ConsciousScreen { agent: std::sync::Arc>, - scroll: u16, - selected: Option, - expanded: std::collections::HashSet, + tree: SectionTree, } impl ConsciousScreen { pub fn new(agent: std::sync::Arc>) -> Self { - Self { agent, scroll: 0, selected: None, expanded: std::collections::HashSet::new() } + Self { agent, tree: SectionTree::new() } } - fn read_context_state(&self) -> Vec { + fn read_context_state(&self) -> Vec { match self.agent.try_lock() { Ok(ag) => ag.context_state_summary(), Err(_) => Vec::new(), } } - - fn item_count(&self, context_state: &[ContextSection]) -> usize { - fn count_section(section: &ContextSection, expanded: &std::collections::HashSet, idx: &mut usize) -> usize { - let my_idx = *idx; - *idx += 1; - let mut total = 1; - if expanded.contains(&my_idx) { - for child in §ion.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, - 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 §ion.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 { @@ -126,32 +41,7 @@ impl ScreenView for ConsciousScreen { if let ratatui::crossterm::event::Event::Key(key) = event { if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } let context_state = self.read_context_state(); - let item_count = self.item_count(&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; } - _ => {} - } + self.tree.handle_nav(key.code, &context_state); } } @@ -185,10 +75,7 @@ impl ScreenView for ConsciousScreen { )); lines.push(Line::raw("")); - let mut flat_idx = 0usize; - for section in &context_state { - self.render_section(section, 0, &mut lines, &mut flat_idx); - } + self.tree.render_sections(&context_state, &mut lines); lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────"))); 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!(" Active tools: {}", app.active_tools.lock().unwrap().len()))); - let block = Block::default() - .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 block = pane_block("context") + .title_top(Line::from(screen_legend()).left_aligned()); let para = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }) - .scroll((self.scroll, 0)); + .scroll((self.tree.scroll, 0)); frame.render_widget(para, area); } diff --git a/src/user/mod.rs b/src/user/mod.rs index 0d0b594..554cab9 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -8,6 +8,7 @@ mod context; mod subconscious; mod unconscious; mod thalamus; +mod widgets; use anyhow::Result; use std::io::Write; diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index 6ecbdf6..dc4039a 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -1,27 +1,52 @@ // 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::{ - layout::Rect, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{List, ListItem, ListState, Paragraph, Wrap}, Frame, crossterm::event::KeyCode, }; use super::{App, ScreenView, screen_legend}; -use crate::agent::context::ConversationEntry; -use crate::agent::api::Role; +use super::widgets::{SectionTree, pane_block_focused, format_age, format_ts_age}; +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 { - selected: usize, - detail: bool, - scroll: u16, + focus: Pane, + list_state: ListState, + output_tree: SectionTree, + context_tree: SectionTree, + history_scroll: u16, } impl SubconsciousScreen { 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, 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 { if let ratatui::crossterm::event::Event::Key(key) = event { if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } match key.code { - KeyCode::Up if !self.detail => { - self.selected = self.selected.saturating_sub(1); + KeyCode::Tab => { + 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 => { - self.selected = (self.selected + 1) - .min(app.agent_state.len().saturating_sub(1)); + KeyCode::BackTab => { + let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0); + self.focus = PANE_ORDER[(idx + PANE_ORDER.len() - 1) % PANE_ORDER.len()]; } - KeyCode::Enter | KeyCode::Right if !self.detail => { - self.detail = true; - self.scroll = 0; + code => match self.focus { + Pane::Agents => match code { + KeyCode::Up => { + self.list_state.select_previous(); + self.reset_pane_state(); + } + KeyCode::Down => { + self.list_state.select_next(); + self.reset_pane_state(); + } + _ => {} + } + 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), } - KeyCode::Esc | KeyCode::Left if self.detail => { - self.detail = false; - } - 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; } - _ => {} } } } - if self.detail { - self.draw_detail(frame, area, app); - } else { - self.draw_list(frame, area, app); - } + // Layout: left column (38%) | right column (62%) + let [left, right] = Layout::horizontal([ + Constraint::Percentage(38), + 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::()) + .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 { - fn draw_list(&self, frame: &mut Frame, area: Rect, app: &App) { - let mut lines: Vec = Vec::new(); - let section = Style::default().fg(Color::Yellow); - let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); + fn reset_pane_state(&mut self) { + self.output_tree = SectionTree::new(); + self.context_tree = SectionTree::new(); + 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("")); + fn output_sections(&self, app: &App) -> Vec { + let snap = match app.agent_state.get(self.selected()) { + Some(s) => s, + None => return Vec::new(), + }; + snap.state.iter().map(|(key, val)| { + ContextSection { + name: key.clone(), + tokens: 0, + content: val.clone(), + children: Vec::new(), + } + }).collect() + } - if app.agent_state.is_empty() { - lines.push(Line::styled(" (no agents loaded)", hint)); - } + fn read_sections(&self, app: &App) -> 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() + } - for (i, snap) in app.agent_state.iter().enumerate() { - let selected = i == self.selected; - let prefix = if selected { "▸ " } else { " " }; - let bg = if selected { - Style::default().bg(Color::DarkGray) - } else { - Style::default() - }; - - let status_spans = if snap.running { - vec![ + fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) { + let items: Vec = 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( - format!("{}{:<30}", prefix, snap.name), - bg.fg(Color::Green), + format!("p:{} t:{}", snap.current_phase, snap.turn), + Style::default().fg(Color::DarkGray), ), - Span::styled("● ", bg.fg(Color::Green)), - Span::styled( - format!("phase: {} turn: {}", snap.current_phase, snap.turn), - bg, - ), - ] + ])) } else { let ago = snap.last_run_secs_ago - .map(|s| { - if s < 60.0 { format!("{:.0}s ago", s) } - 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()); + .map(|s| format_age(s)) + .unwrap_or_else(|| "—".to_string()); let entries = snap.forked_agent.as_ref() .and_then(|a| a.try_lock().ok()) .map(|ag| ag.context.entries.len().saturating_sub(snap.fork_point)) .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( - format!("{}{:<30}", prefix, snap.name), - bg.fg(Color::Gray), + format!("{} {}e", ago, entries), + Style::default().fg(Color::DarkGray), ), - Span::styled("○ ", bg.fg(Color::DarkGray)), - Span::styled( - format!("idle last: {} entries: {}", - ago, entries), - bg.fg(Color::DarkGray), - ), - ] - }; - lines.push(Line::from(status_spans)); + ])) + } + }).collect(); + + let list = List::new(items) + .block(pane_block_focused("agents", self.focus == Pane::Agents) + .title_top(Line::from(screen_legend()).left_aligned())) + .highlight_symbol("▸ ") + .highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_stateful_widget(list, area, &mut self.list_state); + } + + fn draw_outputs(&self, frame: &mut Frame, area: Rect, app: &App) { + let sections = self.output_sections(app); + let mut lines: Vec = Vec::new(); + + 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(§ions, &mut lines); } - let block = Block::default() - .title_top(Line::from(screen_legend()).left_aligned()) - .title_top(Line::from(" subconscious ").right_aligned()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - let para = Paragraph::new(lines) - .block(block) + .block(pane_block_focused("state", self.focus == Pane::Outputs)) .wrap(Wrap { trim: false }) - .scroll((self.scroll, 0)); + .scroll((self.output_tree.scroll, 0)); frame.render_widget(para, area); } - fn draw_detail(&self, frame: &mut Frame, area: Rect, app: &App) { - let snap = match app.agent_state.get(self.selected) { - Some(s) => s, - None => return, - }; + fn draw_history(&self, frame: &mut Frame, area: Rect, app: &App) { + let dim = Style::default().fg(Color::DarkGray); + let key_style = Style::default().fg(Color::Yellow); let mut lines: Vec = Vec::new(); - let section = Style::default().fg(Color::Yellow); - let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); + let mut title = "memory store activity".to_string(); - 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("")); + 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); - // Read entries from the forked agent (from fork point onward) - let entries: Vec = 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( - format!(" [log] {}", text), - Style::default().fg(Color::DarkGray), - )); + 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), + ])); } - 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::>().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, - )); + if !snap.walked.is_empty() { + lines.push(Line::raw("")); + lines.push(Line::styled( + format!(" walked ({}):", snap.walked.len()), + Style::default().fg(Color::Cyan), + )); + 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) - .block(block) + .block(pane_block_focused(&title, self.focus == Pane::History)) .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 = 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); } } diff --git a/src/user/widgets.rs b/src/user/widgets.rs new file mode 100644 index 0000000..4b52918 --- /dev/null +++ b/src/user/widgets.rs @@ -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, + pub expanded: std::collections::HashSet, + 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, idx: &mut usize) -> usize { + let my_idx = *idx; + *idx += 1; + let mut total = 1; + if expanded.contains(&my_idx) { + for child in §ion.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) { + 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, + 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 §ion.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), + )); + } + } + } + } +}