consciousness/src/user/subconscious.rs
ProofOfConcept e17118e4c9 Convert SectionTree and all remaining callers to ScrollPane
SectionTree.scroll is now a ScrollPaneState. All callers of
render_scrollable replaced with ScrollPane::render_stateful_widget.

Deleted render_scrollable and its imports — no hand-rolled scroll
rendering remains outside of scroll_pane.rs.

Co-Authored-By: Kent Overstreet <kent.overstreet@gmail.com>
2026-04-11 01:42:49 -04:00

381 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, 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: super::scroll_pane::ScrollPaneState,
}
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: super::scroll_pane::ScrollPaneState::new(),
}
}
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();
}
KeyCode::Char(' ') => {
if let Some(name) = self.selected_agent_name(app) {
app.agent_toggles.push(name);
}
}
_ => {}
}
Pane::Outputs => self.output_tree.handle_nav(code, &output_sections, area.height),
Pane::History => match code {
KeyCode::Up => self.history_scroll.scroll_up(3),
KeyCode::Down => self.history_scroll.scroll_down(3),
KeyCode::PageUp => self.history_scroll.scroll_up(20),
KeyCode::PageDown => self.history_scroll.scroll_down(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::<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 {
/// Map the selected list index to an agent name.
/// Accounts for the separator line between subconscious and unconscious.
fn selected_agent_name(&self, app: &App) -> Option<String> {
let idx = self.selected();
let sub_count = app.agent_state.len();
if idx < sub_count {
// Subconscious agent
return Some(app.agent_state[idx].name.clone());
}
// Skip separator line
let unc_idx = idx.checked_sub(sub_count + 1)?;
app.unconscious_state.get(unc_idx).map(|s| s.name.clone())
}
fn reset_pane_state(&mut self) {
self.output_tree = SectionTree::new();
self.context_tree = SectionTree::new();
self.history_scroll = super::scroll_pane::ScrollPaneState::new();
}
/// Get the agent Arc for the selected item, whether subconscious or unconscious.
fn selected_agent(&self, app: &App) -> Option<std::sync::Arc<crate::agent::Agent>> {
let idx = self.selected();
let sub_count = app.agent_state.len();
if idx < sub_count {
return app.agent_state[idx].forked_agent.clone();
}
let unc_idx = idx.checked_sub(sub_count + 1)?; // +1 for separator
app.unconscious_state.get(unc_idx)?.agent.clone()
}
fn output_sections(&self, app: &App) -> Vec<SectionView> {
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<SectionView> {
let agent = match self.selected_agent(app) {
Some(a) => a,
None => return Vec::new(),
};
let fork_point = app.agent_state.get(self.selected())
.map(|s| s.fork_point).unwrap_or(0);
agent.context.try_lock().ok()
.map(|ctx| {
let conv = ctx.conversation();
let view = section_to_view("Conversation", conv);
let fork = fork_point.min(view.children.len());
view.children.into_iter().skip(fork).collect()
})
.unwrap_or_default()
}
fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let mut items: Vec<ListItem> = app.agent_state.iter().map(|snap| {
if !snap.enabled {
ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(Color::DarkGray)),
Span::styled(" ○ off", Style::default().fg(Color::DarkGray)),
]))
} else 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 if let Some(ref stats) = snap.last_stats {
format!("×{} {} {}msg {}tc",
snap.runs, ago,
stats.messages, stats.tool_calls)
} 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(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let sections = self.output_sections(app);
let mut lines: Vec<Line> = 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(&sections, &mut lines);
}
let mut block = pane_block_focused("state", self.focus == Pane::Outputs);
if self.focus == Pane::Outputs { block = block.title_bottom(tree_legend()); }
let widget = super::scroll_pane::ScrollPane::new(&lines).block(block);
frame.render_stateful_widget(widget, area, &mut self.output_tree.scroll);
}
fn draw_history(&mut 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<Line> = 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),
));
}
let widget = super::scroll_pane::ScrollPane::new(&lines)
.block(block);
frame.render_stateful_widget(widget, area, &mut self.history_scroll);
}
fn draw_context(
&mut self,
frame: &mut Frame,
area: Rect,
sections: &[SectionView],
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 name = self.selected_agent_name(app);
let title = name.as_deref()
.unwrap_or("");
let mut block = pane_block_focused(title, self.focus == Pane::Context);
if self.focus == Pane::Context { block = block.title_bottom(tree_legend()); }
let widget = super::scroll_pane::ScrollPane::new(&lines).block(block);
frame.render_stateful_widget(widget, area, &mut self.context_tree.scroll);
}
}