consciousness/src/user/subconscious.rs

362 lines
14 KiB
Rust
Raw Normal View History

// 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,
2026-04-06 19:33:18 -04:00
crossterm::event::KeyCode,
};
2026-04-06 19:33:18 -04:00
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,
2026-04-05 23:04:10 -04:00
events: &[ratatui::crossterm::event::Event], app: &mut App) {
let context_sections = self.read_sections(app);
let output_sections = self.output_sections(app);
2026-04-05 23:04:10 -04:00
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 = 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::<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 = 0;
}
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 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<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 {
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<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()); }
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<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),
));
}
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<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 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);
}
}