forked from kent/consciousness
Stats now survive daemon restarts via ~/.consciousness/agent-stats.json, loaded into a global Mutex<HashMap> on first access. Each tool type tracks last count, EWMA (alpha=0.3), and total calls. UI shows a grid view: tool | last | avg | total, sorted by total desc. Failures row appears at bottom if any occurred. Also fixes temperature/priority not being applied to spawned agents. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
491 lines
20 KiB
Rust
491 lines
20 KiB
Rust
// 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, Stats, History, Context }
|
||
|
||
// Clockwise: top-left → right → bottom-left → middle-left
|
||
const PANE_ORDER: &[Pane] = &[Pane::Agents, Pane::Context, Pane::History, Pane::Stats, Pane::Outputs];
|
||
|
||
pub(crate) struct SubconsciousScreen {
|
||
focus: Pane,
|
||
list_state: ListState,
|
||
output_tree: SectionTree,
|
||
context_tree: SectionTree,
|
||
history_scroll: super::scroll_pane::ScrollPaneState,
|
||
stats_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(),
|
||
stats_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::Stats => match code {
|
||
KeyCode::Up => self.stats_scroll.scroll_up(3),
|
||
KeyCode::Down => self.stats_scroll.scroll_down(3),
|
||
KeyCode::PageUp => self.stats_scroll.scroll_up(20),
|
||
KeyCode::PageDown => self.stats_scroll.scroll_down(20),
|
||
_ => {}
|
||
}
|
||
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 | outputs | stats | history
|
||
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 / 5).max(3);
|
||
let stats_lines = self.selected_persisted_stats(app)
|
||
.map(|s| s.by_tool.len())
|
||
.unwrap_or(0);
|
||
let stats_height = (stats_lines as u16 + 2).min(left.height / 5).max(3);
|
||
let [list_area, output_area, stats_area, history_area] = Layout::vertical([
|
||
Constraint::Length(list_height),
|
||
Constraint::Length(output_height),
|
||
Constraint::Length(stats_height),
|
||
Constraint::Min(5),
|
||
]).areas(left);
|
||
|
||
self.draw_list(frame, list_area, app);
|
||
self.draw_outputs(frame, output_area, app);
|
||
self.draw_stats(frame, stats_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();
|
||
self.stats_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()
|
||
}
|
||
|
||
/// Get store activity history for the selected agent.
|
||
fn selected_history<'a>(&self, app: &'a App) -> &'a [(String, i64)] {
|
||
let idx = self.selected();
|
||
let sub_count = app.agent_state.len();
|
||
if idx < sub_count {
|
||
return app.agent_state.get(idx)
|
||
.map(|s| s.history.as_slice())
|
||
.unwrap_or(&[]);
|
||
}
|
||
idx.checked_sub(sub_count + 1)
|
||
.and_then(|i| app.unconscious_state.get(i))
|
||
.map(|s| s.history.as_slice())
|
||
.unwrap_or(&[])
|
||
}
|
||
|
||
/// Get persisted stats for the selected agent.
|
||
fn selected_persisted_stats(&self, app: &App) -> Option<crate::agent::oneshot::PersistedStats> {
|
||
let name = self.selected_agent_name(app)?;
|
||
Some(crate::agent::oneshot::get_stats(&name))
|
||
}
|
||
|
||
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| {
|
||
let (name_color, indicator) = if !snap.enabled {
|
||
(Color::DarkGray, "○")
|
||
} else if snap.running {
|
||
(Color::Green, "●")
|
||
} 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!("p:{} t:{}", snap.current_phase, snap.turn)
|
||
} else if !snap.enabled {
|
||
"off".to_string()
|
||
} else if let Some(ref stats) = snap.last_stats {
|
||
let fail_str = if stats.tool_failures > 0 {
|
||
format!(" {}fail", stats.tool_failures)
|
||
} else {
|
||
String::new()
|
||
};
|
||
format!("×{} {} {}tc{} avg:{:.1}",
|
||
snap.runs, ago,
|
||
stats.tool_calls, fail_str,
|
||
snap.tool_calls_ewma)
|
||
} else {
|
||
format!("×{} {}", snap.runs, ago)
|
||
};
|
||
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::Green } else { Color::DarkGray })),
|
||
Span::styled(detail, 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 {
|
||
let fail_str = if stats.tool_failures > 0 {
|
||
format!(" {}fail", stats.tool_failures)
|
||
} else {
|
||
String::new()
|
||
};
|
||
format!("×{} {} {}tc{} avg:{:.1}",
|
||
snap.runs, ago,
|
||
stats.tool_calls, fail_str,
|
||
snap.tool_calls_ewma)
|
||
} 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(§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()); }
|
||
let widget = super::scroll_pane::ScrollPane::new(&lines).block(block);
|
||
frame.render_stateful_widget(widget, area, &mut self.output_tree.scroll);
|
||
}
|
||
|
||
fn draw_stats(&mut self, frame: &mut Frame, area: Rect, app: &App) {
|
||
let dim = Style::default().fg(Color::DarkGray);
|
||
let header_style = Style::default().fg(Color::DarkGray);
|
||
let name_style = Style::default().fg(Color::Cyan);
|
||
let num_style = Style::default().fg(Color::Yellow);
|
||
|
||
let mut lines: Vec<Line> = Vec::new();
|
||
|
||
if let Some(stats) = self.selected_persisted_stats(app) {
|
||
if !stats.by_tool.is_empty() {
|
||
// Header
|
||
lines.push(Line::from(vec![
|
||
Span::styled(" tool ", header_style),
|
||
Span::styled("last ", header_style),
|
||
Span::styled(" avg ", header_style),
|
||
Span::styled("total", header_style),
|
||
]));
|
||
|
||
// Sort by total descending
|
||
let mut tools: Vec<_> = stats.by_tool.iter().collect();
|
||
tools.sort_by(|a, b| b.1.total.cmp(&a.1.total));
|
||
|
||
for (name, tool_stats) in tools {
|
||
let short_name = name.strip_prefix("memory_").unwrap_or(name);
|
||
lines.push(Line::from(vec![
|
||
Span::styled(format!(" {:<20} ", short_name), name_style),
|
||
Span::styled(format!("{:>4} ", tool_stats.last), num_style),
|
||
Span::styled(format!("{:>4.1} ", tool_stats.ewma), dim),
|
||
Span::styled(format!("{:>5}", tool_stats.total), num_style),
|
||
]));
|
||
}
|
||
|
||
// Failures row if any
|
||
if stats.failures.total > 0 {
|
||
lines.push(Line::raw(""));
|
||
lines.push(Line::from(vec![
|
||
Span::styled(" failures ", Style::default().fg(Color::Red)),
|
||
Span::styled(format!("{:>4} ", stats.failures.last), num_style),
|
||
Span::styled(format!("{:>4.1} ", stats.failures.ewma), dim),
|
||
Span::styled(format!("{:>5}", stats.failures.total), num_style),
|
||
]));
|
||
}
|
||
}
|
||
}
|
||
|
||
if lines.is_empty() {
|
||
lines.push(Line::styled(" (no tool calls)", dim));
|
||
}
|
||
|
||
let mut block = pane_block_focused("tool calls", self.focus == Pane::Stats);
|
||
if self.focus == Pane::Stats {
|
||
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.stats_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 name = self.selected_agent_name(app);
|
||
let short_name = name.as_ref()
|
||
.map(|n| n.strip_prefix("subconscious-").unwrap_or(n))
|
||
.unwrap_or("—");
|
||
let title = format!("{} store activity", short_name);
|
||
|
||
let history = self.selected_history(app);
|
||
if history.is_empty() {
|
||
lines.push(Line::styled(" (no store activity)", dim));
|
||
} else {
|
||
for (key, ts) in history {
|
||
lines.push(Line::from(vec![
|
||
Span::styled(format!(" {:>6} ", format_ts_age(*ts)), dim),
|
||
Span::styled(key.as_str(), key_style),
|
||
]));
|
||
}
|
||
}
|
||
|
||
// Walked state (subconscious only)
|
||
if let Some(snap) = app.agent_state.get(self.selected()) {
|
||
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);
|
||
}
|
||
}
|