// 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::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{List, ListItem, ListState}, Frame, crossterm::event::KeyCode, }; use super::{App, ScreenView, screen_legend}; use super::widgets::{SectionTree, SectionView, section_to_view, pane_block_focused, render_scrollable, tree_legend, format_age, format_ts_age}; #[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 { focus: Pane, list_state: ListState, output_tree: SectionTree, context_tree: SectionTree, history_scroll: u16, } impl SubconsciousScreen { pub fn new() -> Self { 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) } } impl ScreenView for SubconsciousScreen { fn label(&self) -> &'static str { "subconscious" } 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::Tab => { let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0); self.focus = PANE_ORDER[(idx + 1) % PANE_ORDER.len()]; } 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()]; } 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, area.height), 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, area.height), } } } } // 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 unc_count = if app.unconscious_state.is_empty() { 0 } else { app.unconscious_state.len() + 1 }; // +1 for separator let agent_count = (app.agent_state.len() + unc_count).max(1) as u16; let list_height = (agent_count + 2).min(left.height / 3); 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 reset_pane_state(&mut self) { self.output_tree = SectionTree::new(); self.context_tree = SectionTree::new(); self.history_scroll = 0; } 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)| { SectionView { name: key.clone(), tokens: 0, content: val.clone(), children: Vec::new(), status: String::new(), } }).collect() } 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.context.try_lock().ok()) .map(|ctx| { let conv = ctx.conversation(); let mut view = section_to_view("Conversation", conv); let fork = snap.fork_point.min(view.children.len()); view.children = view.children.split_off(fork); vec![view] }) .unwrap_or_default() } fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) { let mut 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!("p:{} t:{}", snap.current_phase, snap.turn), Style::default().fg(Color::DarkGray), ), ])) } else { let ago = snap.last_run_secs_ago .map(|s| format_age(s)) .unwrap_or_else(|| "—".to_string()); let entries = snap.forked_agent.as_ref() .and_then(|a| a.context.try_lock().ok()) .map(|ctx| ctx.conversation().len().saturating_sub(snap.fork_point)) .unwrap_or(0); ListItem::from(Line::from(vec![ Span::styled(&snap.name, Style::default().fg(Color::Gray)), Span::styled(" ○ ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{} {}e", ago, entries), Style::default().fg(Color::DarkGray), ), ])) } }).collect(); // Unconscious agents (graph maintenance) if !app.unconscious_state.is_empty() { items.push(ListItem::from(Line::styled( "── unconscious ──", Style::default().fg(Color::DarkGray), ))); for snap in &app.unconscious_state { let (name_color, indicator) = if !snap.enabled { (Color::DarkGray, "○") } else if snap.running { (Color::Yellow, "●") } else { (Color::Gray, "○") }; let ago = snap.last_run_secs_ago .map(|s| format_age(s)) .unwrap_or_else(|| "—".to_string()); let detail = if snap.running { format!("run {}", snap.runs + 1) } else if !snap.enabled { "off".to_string() } else { format!("×{} {}", snap.runs, ago) }; items.push(ListItem::from(Line::from(vec![ Span::styled(&snap.name, Style::default().fg(name_color)), Span::styled(format!(" {} ", indicator), Style::default().fg(if snap.running { Color::Yellow } else { Color::DarkGray })), Span::styled(detail, Style::default().fg(Color::DarkGray)), ]))); } } let mut block = pane_block_focused("agents", self.focus == Pane::Agents) .title_top(Line::from(screen_legend()).left_aligned()); if self.focus == Pane::Agents { block = block.title_bottom(Line::styled( " ↑↓:select Tab:next pane ", Style::default().fg(Color::DarkGray), )); } let list = List::new(items) .block(block) .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 mut block = pane_block_focused("state", self.focus == Pane::Outputs); if self.focus == Pane::Outputs { block = block.title_bottom(tree_legend()); } render_scrollable(frame, area, lines, block, self.output_tree.scroll); } 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 mut title = "memory store activity".to_string(); 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 let Some(walked_str) = snap.state.get("walked") { let walked: Vec<&str> = walked_str.lines() .map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); if !walked.is_empty() { lines.push(Line::raw("")); lines.push(Line::styled( format!(" walked ({}):", walked.len()), Style::default().fg(Color::Cyan), )); for key in &walked { lines.push(Line::styled(format!(" {}", key), dim)); } } } } let mut block = pane_block_focused(&title, self.focus == Pane::History); if self.focus == Pane::History { block = block.title_bottom(Line::styled( " ↑↓:scroll PgUp/Dn ", Style::default().fg(Color::DarkGray), )); } render_scrollable(frame, area, lines, block, self.history_scroll); } fn draw_context( &self, frame: &mut Frame, area: Rect, sections: &[SectionView], 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 mut block = pane_block_focused(title, self.focus == Pane::Context); if self.focus == Pane::Context { block = block.title_bottom(tree_legend()); } render_scrollable(frame, area, lines, block, self.context_tree.scroll); } }