consciousness/src/user/subconscious.rs
ProofOfConcept aade8a9cce Add per-agent run stats (messages, tool calls by type)
compute_run_stats() walks the conversation AST after each agent
completes, counting messages and tool calls by tool name. Stats
are returned from save_agent_log(), stored on UnconsciousAgent,
and displayed in the agent list UI.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-10 13:44:41 -04:00

377 lines
15 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, 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();
}
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;
}
/// 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(&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 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()); }
render_scrollable(frame, area, lines, block, self.context_tree.scroll);
}
}