Three-pane subconscious debug screen with shared widgets
New layout for F3 screen: - Top-left: agent list using ratatui List widget with ListState - Middle-left: expandable agent state (persistent across runs) - Bottom-left: memory store activity by provenance, walked keys - Right: context tree from fork point, reusing SectionTree Tab/Shift-Tab cycles focus clockwise between panes; focused pane gets white border. Each pane handles its own input when focused. Extracted user/widgets.rs: - SectionTree (moved from mod.rs): expand/collapse tree for ContextSection - pane_block_focused(): standard bordered block with focus indicator - format_age()/format_ts_age(): shared duration formatting Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
edfa1c37f5
commit
818cdcc4e5
4 changed files with 399 additions and 288 deletions
|
|
@ -1,120 +1,35 @@
|
||||||
// context_screen.rs — F2 context/debug overlay
|
// context_screen.rs — F2 context/debug overlay
|
||||||
//
|
//
|
||||||
// Full-screen overlay showing model info, context window breakdown,
|
// Full-screen overlay showing model info, context window breakdown,
|
||||||
// and runtime state. Supports tree navigation with expand/collapse.
|
// and runtime state. Uses SectionTree for the expand/collapse tree.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Style},
|
||||||
text::Line,
|
text::Line,
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
crossterm::event::KeyCode,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{App, ScreenView, screen_legend};
|
use super::{App, ScreenView, screen_legend};
|
||||||
use crate::agent::context::ContextSection;
|
use super::widgets::{SectionTree, pane_block};
|
||||||
|
|
||||||
pub(crate) struct ConsciousScreen {
|
pub(crate) struct ConsciousScreen {
|
||||||
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
|
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
|
||||||
scroll: u16,
|
tree: SectionTree,
|
||||||
selected: Option<usize>,
|
|
||||||
expanded: std::collections::HashSet<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsciousScreen {
|
impl ConsciousScreen {
|
||||||
pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self {
|
pub fn new(agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>) -> Self {
|
||||||
Self { agent, scroll: 0, selected: None, expanded: std::collections::HashSet::new() }
|
Self { agent, tree: SectionTree::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_context_state(&self) -> Vec<ContextSection> {
|
fn read_context_state(&self) -> Vec<crate::agent::context::ContextSection> {
|
||||||
match self.agent.try_lock() {
|
match self.agent.try_lock() {
|
||||||
Ok(ag) => ag.context_state_summary(),
|
Ok(ag) => ag.context_state_summary(),
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_count(&self, context_state: &[ContextSection]) -> usize {
|
|
||||||
fn count_section(section: &ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
|
|
||||||
let my_idx = *idx;
|
|
||||||
*idx += 1;
|
|
||||||
let mut total = 1;
|
|
||||||
if expanded.contains(&my_idx) {
|
|
||||||
for child in §ion.children {
|
|
||||||
total += count_section(child, expanded, idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
let mut idx = 0;
|
|
||||||
let mut total = 0;
|
|
||||||
for section in context_state {
|
|
||||||
total += count_section(section, &self.expanded, &mut idx);
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scroll_to_selected(&mut self, _item_count: usize) {
|
|
||||||
let header_lines = 8u16;
|
|
||||||
if let Some(sel) = self.selected {
|
|
||||||
let sel_line = header_lines + sel as u16;
|
|
||||||
if sel_line < self.scroll + 2 {
|
|
||||||
self.scroll = sel_line.saturating_sub(2);
|
|
||||||
} else if sel_line > self.scroll + 30 {
|
|
||||||
self.scroll = sel_line.saturating_sub(15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_section(
|
|
||||||
&self,
|
|
||||||
section: &ContextSection,
|
|
||||||
depth: usize,
|
|
||||||
lines: &mut Vec<Line>,
|
|
||||||
idx: &mut usize,
|
|
||||||
) {
|
|
||||||
let my_idx = *idx;
|
|
||||||
let selected = self.selected == Some(my_idx);
|
|
||||||
let expanded = self.expanded.contains(&my_idx);
|
|
||||||
let has_children = !section.children.is_empty();
|
|
||||||
let has_content = !section.content.is_empty();
|
|
||||||
let expandable = has_children || has_content;
|
|
||||||
|
|
||||||
let indent = " ".repeat(depth + 1);
|
|
||||||
let marker = if !expandable { " " } else if expanded { "▼" } else { "▶" };
|
|
||||||
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
|
|
||||||
let style = if selected {
|
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
lines.push(Line::styled(label, style));
|
|
||||||
*idx += 1;
|
|
||||||
|
|
||||||
if expanded {
|
|
||||||
if has_children {
|
|
||||||
for child in §ion.children {
|
|
||||||
self.render_section(child, depth + 1, lines, idx);
|
|
||||||
}
|
|
||||||
} else if has_content {
|
|
||||||
let content_indent = format!("{} │ ", " ".repeat(depth + 1));
|
|
||||||
let content_lines: Vec<&str> = section.content.lines().collect();
|
|
||||||
let show = content_lines.len().min(50);
|
|
||||||
for line in &content_lines[..show] {
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!("{}{}", content_indent, line),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if content_lines.len() > 50 {
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScreenView for ConsciousScreen {
|
impl ScreenView for ConsciousScreen {
|
||||||
|
|
@ -126,32 +41,7 @@ impl ScreenView for ConsciousScreen {
|
||||||
if let ratatui::crossterm::event::Event::Key(key) = event {
|
if let ratatui::crossterm::event::Event::Key(key) = event {
|
||||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
|
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
|
||||||
let context_state = self.read_context_state();
|
let context_state = self.read_context_state();
|
||||||
let item_count = self.item_count(&context_state);
|
self.tree.handle_nav(key.code, &context_state);
|
||||||
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Up => {
|
|
||||||
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
|
|
||||||
self.scroll_to_selected(item_count);
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
let max = item_count.saturating_sub(1);
|
|
||||||
self.selected = Some(self.selected.map_or(0, |s| (s + 1).min(max)));
|
|
||||||
self.scroll_to_selected(item_count);
|
|
||||||
}
|
|
||||||
KeyCode::Right | KeyCode::Enter => {
|
|
||||||
if let Some(sel) = self.selected {
|
|
||||||
self.expanded.insert(sel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
if let Some(sel) = self.selected {
|
|
||||||
self.expanded.remove(&sel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
|
|
||||||
KeyCode::PageDown => { self.scroll += 20; }
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,10 +75,7 @@ impl ScreenView for ConsciousScreen {
|
||||||
));
|
));
|
||||||
lines.push(Line::raw(""));
|
lines.push(Line::raw(""));
|
||||||
|
|
||||||
let mut flat_idx = 0usize;
|
self.tree.render_sections(&context_state, &mut lines);
|
||||||
for section in &context_state {
|
|
||||||
self.render_section(section, 0, &mut lines, &mut flat_idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────")));
|
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "────────", "──────")));
|
||||||
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total)));
|
lines.push(Line::raw(format!(" {:23} {:>6} tokens", "Total", total)));
|
||||||
|
|
@ -207,16 +94,13 @@ impl ScreenView for ConsciousScreen {
|
||||||
lines.push(Line::raw(format!(" Running processes: {}", app.running_processes)));
|
lines.push(Line::raw(format!(" Running processes: {}", app.running_processes)));
|
||||||
lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len())));
|
lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len())));
|
||||||
|
|
||||||
let block = Block::default()
|
let block = pane_block("context")
|
||||||
.title_top(Line::from(screen_legend()).left_aligned())
|
.title_top(Line::from(screen_legend()).left_aligned());
|
||||||
.title_top(Line::from(" context ").right_aligned())
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Cyan));
|
|
||||||
|
|
||||||
let para = Paragraph::new(lines)
|
let para = Paragraph::new(lines)
|
||||||
.block(block)
|
.block(block)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((self.scroll, 0));
|
.scroll((self.tree.scroll, 0));
|
||||||
|
|
||||||
frame.render_widget(para, area);
|
frame.render_widget(para, area);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ mod context;
|
||||||
mod subconscious;
|
mod subconscious;
|
||||||
mod unconscious;
|
mod unconscious;
|
||||||
mod thalamus;
|
mod thalamus;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,52 @@
|
||||||
// subconscious_screen.rs — F3 subconscious agent overlay
|
// 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::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{List, ListItem, ListState, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
crossterm::event::KeyCode,
|
crossterm::event::KeyCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{App, ScreenView, screen_legend};
|
use super::{App, ScreenView, screen_legend};
|
||||||
use crate::agent::context::ConversationEntry;
|
use super::widgets::{SectionTree, pane_block_focused, format_age, format_ts_age};
|
||||||
use crate::agent::api::Role;
|
use crate::agent::context::ContextSection;
|
||||||
|
|
||||||
|
#[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 {
|
pub(crate) struct SubconsciousScreen {
|
||||||
selected: usize,
|
focus: Pane,
|
||||||
detail: bool,
|
list_state: ListState,
|
||||||
scroll: u16,
|
output_tree: SectionTree,
|
||||||
|
context_tree: SectionTree,
|
||||||
|
history_scroll: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubconsciousScreen {
|
impl SubconsciousScreen {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { selected: 0, detail: false, scroll: 0 }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,206 +55,232 @@ impl ScreenView for SubconsciousScreen {
|
||||||
|
|
||||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||||
events: &[ratatui::crossterm::event::Event], app: &mut App) {
|
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 {
|
for event in events {
|
||||||
if let ratatui::crossterm::event::Event::Key(key) = event {
|
if let ratatui::crossterm::event::Event::Key(key) = event {
|
||||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
|
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up if !self.detail => {
|
KeyCode::Tab => {
|
||||||
self.selected = self.selected.saturating_sub(1);
|
let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
|
||||||
|
self.focus = PANE_ORDER[(idx + 1) % PANE_ORDER.len()];
|
||||||
}
|
}
|
||||||
KeyCode::Down if !self.detail => {
|
KeyCode::BackTab => {
|
||||||
self.selected = (self.selected + 1)
|
let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
|
||||||
.min(app.agent_state.len().saturating_sub(1));
|
self.focus = PANE_ORDER[(idx + PANE_ORDER.len() - 1) % PANE_ORDER.len()];
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Right if !self.detail => {
|
code => match self.focus {
|
||||||
self.detail = true;
|
Pane::Agents => match code {
|
||||||
self.scroll = 0;
|
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),
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
KeyCode::Esc | KeyCode::Left if self.detail => {
|
|
||||||
self.detail = false;
|
|
||||||
}
|
|
||||||
KeyCode::Up if self.detail => {
|
|
||||||
self.scroll = self.scroll.saturating_sub(3);
|
|
||||||
}
|
|
||||||
KeyCode::Down if self.detail => {
|
|
||||||
self.scroll += 3;
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
|
|
||||||
KeyCode::PageDown => { self.scroll += 20; }
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.detail {
|
// Layout: left column (38%) | right column (62%)
|
||||||
self.draw_detail(frame, area, app);
|
let [left, right] = Layout::horizontal([
|
||||||
} else {
|
Constraint::Percentage(38),
|
||||||
self.draw_list(frame, area, app);
|
Constraint::Percentage(62),
|
||||||
}
|
]).areas(area);
|
||||||
|
|
||||||
|
// Left column: agent list (top) | outputs (middle) | history (bottom, main)
|
||||||
|
let agent_count = app.agent_state.len().max(1) as u16;
|
||||||
|
let list_height = (agent_count + 2).min(left.height / 4);
|
||||||
|
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 {
|
impl SubconsciousScreen {
|
||||||
fn draw_list(&self, frame: &mut Frame, area: Rect, app: &App) {
|
fn reset_pane_state(&mut self) {
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
self.output_tree = SectionTree::new();
|
||||||
let section = Style::default().fg(Color::Yellow);
|
self.context_tree = SectionTree::new();
|
||||||
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
|
self.history_scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(Line::raw(""));
|
fn output_sections(&self, app: &App) -> Vec<ContextSection> {
|
||||||
let walked = app.walked_count;
|
let snap = match app.agent_state.get(self.selected()) {
|
||||||
lines.push(Line::styled(
|
Some(s) => s,
|
||||||
format!("── Subconscious Agents ── walked: {}", walked), section));
|
None => return Vec::new(),
|
||||||
lines.push(Line::styled(" (↑/↓ select, Enter view log)", hint));
|
};
|
||||||
lines.push(Line::raw(""));
|
snap.state.iter().map(|(key, val)| {
|
||||||
|
ContextSection {
|
||||||
|
name: key.clone(),
|
||||||
|
tokens: 0,
|
||||||
|
content: val.clone(),
|
||||||
|
children: Vec::new(),
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
if app.agent_state.is_empty() {
|
fn read_sections(&self, app: &App) -> Vec<ContextSection> {
|
||||||
lines.push(Line::styled(" (no agents loaded)", hint));
|
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.try_lock().ok())
|
||||||
|
.map(|ag| ag.conversation_sections_from(snap.fork_point))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
for (i, snap) in app.agent_state.iter().enumerate() {
|
fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let selected = i == self.selected;
|
let items: Vec<ListItem> = app.agent_state.iter().map(|snap| {
|
||||||
let prefix = if selected { "▸ " } else { " " };
|
if snap.running {
|
||||||
let bg = if selected {
|
ListItem::from(Line::from(vec![
|
||||||
Style::default().bg(Color::DarkGray)
|
Span::styled(&snap.name, Style::default().fg(Color::Green)),
|
||||||
} else {
|
Span::styled(" ● ", Style::default().fg(Color::Green)),
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_spans = if snap.running {
|
|
||||||
vec![
|
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{}{:<30}", prefix, snap.name),
|
format!("p:{} t:{}", snap.current_phase, snap.turn),
|
||||||
bg.fg(Color::Green),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
Span::styled("● ", bg.fg(Color::Green)),
|
]))
|
||||||
Span::styled(
|
|
||||||
format!("phase: {} turn: {}", snap.current_phase, snap.turn),
|
|
||||||
bg,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
let ago = snap.last_run_secs_ago
|
let ago = snap.last_run_secs_ago
|
||||||
.map(|s| {
|
.map(|s| format_age(s))
|
||||||
if s < 60.0 { format!("{:.0}s ago", s) }
|
.unwrap_or_else(|| "—".to_string());
|
||||||
else if s < 3600.0 { format!("{:.0}m ago", s / 60.0) }
|
|
||||||
else { format!("{:.1}h ago", s / 3600.0) }
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "never".to_string());
|
|
||||||
let entries = snap.forked_agent.as_ref()
|
let entries = snap.forked_agent.as_ref()
|
||||||
.and_then(|a| a.try_lock().ok())
|
.and_then(|a| a.try_lock().ok())
|
||||||
.map(|ag| ag.context.entries.len().saturating_sub(snap.fork_point))
|
.map(|ag| ag.context.entries.len().saturating_sub(snap.fork_point))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
vec![
|
ListItem::from(Line::from(vec![
|
||||||
|
Span::styled(&snap.name, Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(" ○ ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{}{:<30}", prefix, snap.name),
|
format!("{} {}e", ago, entries),
|
||||||
bg.fg(Color::Gray),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
Span::styled("○ ", bg.fg(Color::DarkGray)),
|
]))
|
||||||
Span::styled(
|
}
|
||||||
format!("idle last: {} entries: {}",
|
}).collect();
|
||||||
ago, entries),
|
|
||||||
bg.fg(Color::DarkGray),
|
let list = List::new(items)
|
||||||
),
|
.block(pane_block_focused("agents", self.focus == Pane::Agents)
|
||||||
]
|
.title_top(Line::from(screen_legend()).left_aligned()))
|
||||||
};
|
.highlight_symbol("▸ ")
|
||||||
lines.push(Line::from(status_spans));
|
.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(§ions, &mut lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
let block = Block::default()
|
|
||||||
.title_top(Line::from(screen_legend()).left_aligned())
|
|
||||||
.title_top(Line::from(" subconscious ").right_aligned())
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Cyan));
|
|
||||||
|
|
||||||
let para = Paragraph::new(lines)
|
let para = Paragraph::new(lines)
|
||||||
.block(block)
|
.block(pane_block_focused("state", self.focus == Pane::Outputs))
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((self.scroll, 0));
|
.scroll((self.output_tree.scroll, 0));
|
||||||
frame.render_widget(para, area);
|
frame.render_widget(para, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_detail(&self, frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_history(&self, frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let snap = match app.agent_state.get(self.selected) {
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
Some(s) => s,
|
let key_style = Style::default().fg(Color::Yellow);
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
let section = Style::default().fg(Color::Yellow);
|
let mut title = "memory store activity".to_string();
|
||||||
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
|
|
||||||
|
|
||||||
lines.push(Line::raw(""));
|
if let Some(snap) = app.agent_state.get(self.selected()) {
|
||||||
lines.push(Line::styled(format!("── {} ──", snap.name), section));
|
let short_name = snap.name.strip_prefix("subconscious-").unwrap_or(&snap.name);
|
||||||
lines.push(Line::styled(" (Esc/← back, ↑/↓/PgUp/PgDn scroll)", hint));
|
title = format!("{} store activity", short_name);
|
||||||
lines.push(Line::raw(""));
|
|
||||||
|
|
||||||
// Read entries from the forked agent (from fork point onward)
|
if snap.history.is_empty() {
|
||||||
let entries: Vec<ConversationEntry> = snap.forked_agent.as_ref()
|
lines.push(Line::styled(" (no store activity)", dim));
|
||||||
.and_then(|agent| agent.try_lock().ok())
|
} else {
|
||||||
.map(|ag| ag.context.entries.get(snap.fork_point..).unwrap_or(&[]).to_vec())
|
for (key, ts) in &snap.history {
|
||||||
.unwrap_or_default();
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {:>6} ", format_ts_age(*ts)), dim),
|
||||||
if entries.is_empty() {
|
Span::styled(key.as_str(), key_style),
|
||||||
lines.push(Line::styled(" (no run data)", hint));
|
]));
|
||||||
}
|
|
||||||
|
|
||||||
for entry in &entries {
|
|
||||||
if entry.is_log() {
|
|
||||||
if let ConversationEntry::Log(text) = entry {
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!(" [log] {}", text),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = entry.message();
|
if !snap.walked.is_empty() {
|
||||||
let (role_str, role_color) = match msg.role {
|
lines.push(Line::raw(""));
|
||||||
Role::User => ("user", Color::Cyan),
|
lines.push(Line::styled(
|
||||||
Role::Assistant => ("assistant", Color::Reset),
|
format!(" walked ({}):", snap.walked.len()),
|
||||||
Role::Tool => ("tool", Color::DarkGray),
|
Style::default().fg(Color::Cyan),
|
||||||
Role::System => ("system", Color::Yellow),
|
));
|
||||||
};
|
for key in &snap.walked {
|
||||||
|
lines.push(Line::styled(format!(" {}", key), dim));
|
||||||
let text = msg.content_text();
|
|
||||||
let tool_info = msg.tool_calls.as_ref().map(|tc| {
|
|
||||||
tc.iter().map(|c| c.function.name.as_str())
|
|
||||||
.collect::<Vec<_>>().join(", ")
|
|
||||||
});
|
|
||||||
|
|
||||||
let header = match &tool_info {
|
|
||||||
Some(tools) => format!(" [{} → {}]", role_str, tools),
|
|
||||||
None => format!(" [{}]", role_str),
|
|
||||||
};
|
|
||||||
lines.push(Line::styled(header, Style::default().fg(role_color)));
|
|
||||||
|
|
||||||
if !text.is_empty() {
|
|
||||||
for line in text.lines().take(20) {
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!(" {}", line),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if text.lines().count() > 20 {
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!(" ... ({} more lines)", text.lines().count() - 20),
|
|
||||||
hint,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let block = Block::default()
|
|
||||||
.title_top(Line::from(screen_legend()).left_aligned())
|
|
||||||
.title_top(Line::from(format!(" {} ", snap.name)).right_aligned())
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Cyan));
|
|
||||||
|
|
||||||
let para = Paragraph::new(lines)
|
let para = Paragraph::new(lines)
|
||||||
.block(block)
|
.block(pane_block_focused(&title, self.focus == Pane::History))
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((self.scroll, 0));
|
.scroll((self.history_scroll, 0));
|
||||||
|
frame.render_widget(para, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_context(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
sections: &[ContextSection],
|
||||||
|
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 para = Paragraph::new(lines)
|
||||||
|
.block(pane_block_focused(title, self.focus == Pane::Context))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((self.context_tree.scroll, 0));
|
||||||
frame.render_widget(para, area);
|
frame.render_widget(para, area);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
src/user/widgets.rs
Normal file
175
src/user/widgets.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
// widgets.rs — Shared TUI helpers and reusable components
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders},
|
||||||
|
crossterm::event::KeyCode,
|
||||||
|
};
|
||||||
|
use crate::agent::context::ContextSection;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Standard pane block — cyan border, right-aligned title.
|
||||||
|
pub fn pane_block(title: &str) -> Block<'_> {
|
||||||
|
Block::default()
|
||||||
|
.title_top(Line::from(format!(" {} ", title)).right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focused pane block — brighter border to indicate focus.
|
||||||
|
pub fn pane_block_focused(title: &str, focused: bool) -> Block<'_> {
|
||||||
|
let color = if focused { Color::White } else { Color::Cyan };
|
||||||
|
Block::default()
|
||||||
|
.title_top(Line::from(format!(" {} ", title)).right_aligned())
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a duration in seconds as a compact human-readable string.
|
||||||
|
pub fn format_age(secs: f64) -> String {
|
||||||
|
if secs < 60.0 { format!("{:.0}s", secs) }
|
||||||
|
else if secs < 3600.0 { format!("{:.0}m", secs / 60.0) }
|
||||||
|
else if secs < 86400.0 { format!("{:.1}h", secs / 3600.0) }
|
||||||
|
else { format!("{:.0}d", secs / 86400.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a unix epoch timestamp as age from now.
|
||||||
|
pub fn format_ts_age(ts: i64) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
format_age((now - ts).max(0) as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SectionTree — expand/collapse tree renderer for ContextSection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct SectionTree {
|
||||||
|
pub selected: Option<usize>,
|
||||||
|
pub expanded: std::collections::HashSet<usize>,
|
||||||
|
pub scroll: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionTree {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { selected: None, expanded: std::collections::HashSet::new(), scroll: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_count(&self, sections: &[ContextSection]) -> usize {
|
||||||
|
fn count(section: &ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
|
||||||
|
let my_idx = *idx;
|
||||||
|
*idx += 1;
|
||||||
|
let mut total = 1;
|
||||||
|
if expanded.contains(&my_idx) {
|
||||||
|
for child in §ion.children {
|
||||||
|
total += count(child, expanded, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
let mut idx = 0;
|
||||||
|
sections.iter().map(|s| count(s, &self.expanded, &mut idx)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_nav(&mut self, code: KeyCode, sections: &[ContextSection]) {
|
||||||
|
let item_count = self.item_count(sections);
|
||||||
|
match code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
|
||||||
|
self.scroll_to_selected();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
let max = item_count.saturating_sub(1);
|
||||||
|
self.selected = Some(self.selected.map_or(0, |s| (s + 1).min(max)));
|
||||||
|
self.scroll_to_selected();
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Enter => {
|
||||||
|
if let Some(sel) = self.selected {
|
||||||
|
self.expanded.insert(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
if let Some(sel) = self.selected {
|
||||||
|
self.expanded.remove(&sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
|
||||||
|
KeyCode::PageDown => { self.scroll += 20; }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_to_selected(&mut self) {
|
||||||
|
if let Some(sel) = self.selected {
|
||||||
|
let sel_line = sel as u16;
|
||||||
|
if sel_line < self.scroll + 2 {
|
||||||
|
self.scroll = sel_line.saturating_sub(2);
|
||||||
|
} else if sel_line > self.scroll + 30 {
|
||||||
|
self.scroll = sel_line.saturating_sub(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_sections(&self, sections: &[ContextSection], lines: &mut Vec<Line>) {
|
||||||
|
let mut idx = 0;
|
||||||
|
for section in sections {
|
||||||
|
self.render_one(section, 0, lines, &mut idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_one(
|
||||||
|
&self,
|
||||||
|
section: &ContextSection,
|
||||||
|
depth: usize,
|
||||||
|
lines: &mut Vec<Line>,
|
||||||
|
idx: &mut usize,
|
||||||
|
) {
|
||||||
|
let my_idx = *idx;
|
||||||
|
let selected = self.selected == Some(my_idx);
|
||||||
|
let expanded = self.expanded.contains(&my_idx);
|
||||||
|
let has_children = !section.children.is_empty();
|
||||||
|
let has_content = !section.content.is_empty();
|
||||||
|
let expandable = has_children || has_content;
|
||||||
|
|
||||||
|
let indent = " ".repeat(depth + 1);
|
||||||
|
let marker = if !expandable { " " } else if expanded { "▼" } else { "▶" };
|
||||||
|
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
|
||||||
|
let style = if selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
lines.push(Line::styled(label, style));
|
||||||
|
*idx += 1;
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
if has_children {
|
||||||
|
for child in §ion.children {
|
||||||
|
self.render_one(child, depth + 1, lines, idx);
|
||||||
|
}
|
||||||
|
} else if has_content {
|
||||||
|
let content_indent = format!("{} │ ", " ".repeat(depth + 1));
|
||||||
|
let content_lines: Vec<&str> = section.content.lines().collect();
|
||||||
|
let show = content_lines.len().min(50);
|
||||||
|
for line in &content_lines[..show] {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("{}{}", content_indent, line),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if content_lines.len() > 50 {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue