Restore context tree display with SectionView UI type

Introduced SectionView {name, tokens, content, children} as a
UI-only tree node, separate from the data ContextSection. The widget
SectionTree renders SectionView with the old recursive expand/collapse
behavior — children for sub-sections, content for text expansion.

section_to_view() converts data sections to UI views, using
ConversationEntry::label() for names and content_text() for
expandable content.

read_context_views() builds the same tree the old context_state_summary
did: System, Identity, Journal, Memory nodes (scored/unscored counts,
expandable to show content), Conversation entries.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 22:06:10 -04:00
parent 613704720b
commit 07b400c95c
3 changed files with 132 additions and 105 deletions

View file

@ -11,7 +11,7 @@ use ratatui::{
}; };
use super::{App, ScreenView, screen_legend}; use super::{App, ScreenView, screen_legend};
use super::widgets::{SectionTree, pane_block, render_scrollable, tree_legend}; use super::widgets::{SectionTree, SectionView, section_to_view, pane_block, render_scrollable, tree_legend};
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>>,
@ -23,42 +23,53 @@ impl ConsciousScreen {
Self { agent, tree: SectionTree::new() } Self { agent, tree: SectionTree::new() }
} }
fn read_context_sections(&self) -> Vec<crate::agent::context::ContextSection> { fn read_context_views(&self) -> Vec<SectionView> {
use crate::agent::context::{ContextSection, ContextEntry, ConversationEntry}; use crate::agent::context::ConversationEntry;
use crate::agent::api::Message;
let ag = match self.agent.try_lock() { let ag = match self.agent.try_lock() {
Ok(ag) => ag, Ok(ag) => ag,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
let mut sections: Vec<ContextSection> = ag.context_sections() let mut views: Vec<SectionView> = Vec::new();
.iter().map(|s| (*s).clone()).collect();
// Build a synthetic "Memory nodes" section from conversation entries // System, Identity, Journal — simple section-to-view
let mut mem_section = ContextSection::new("Memory nodes"); views.push(section_to_view(&ag.context.system));
views.push(section_to_view(&ag.context.identity));
views.push(section_to_view(&ag.context.journal));
// Memory nodes — extracted from conversation, shown as children
let mut mem_children: Vec<SectionView> = Vec::new();
let mut scored = 0usize; let mut scored = 0usize;
let mut unscored = 0usize; let mut unscored = 0usize;
for ce in ag.context.conversation.entries() { for ce in ag.context.conversation.entries() {
if let ConversationEntry::Memory { key, score, .. } = &ce.entry { if let ConversationEntry::Memory { key, score, .. } = &ce.entry {
let label = match score { let label = match score {
Some(s) => { scored += 1; format!("{} (score:{:.2})", key, s) } Some(s) => { scored += 1; format!("{} (score:{:.1})", key, s) }
None => { unscored += 1; key.clone() } None => { unscored += 1; key.clone() }
}; };
mem_section.push(ContextEntry { mem_children.push(SectionView {
entry: ConversationEntry::Message(Message::user(&label)), name: label,
tokens: ce.tokens, tokens: ce.tokens,
timestamp: ce.timestamp, content: ce.entry.message().content_text().to_string(),
children: Vec::new(),
}); });
} }
} }
if !mem_section.is_empty() { if !mem_children.is_empty() {
mem_section.name = format!("Memory nodes ({} scored, {} unscored)", let mem_tokens: usize = mem_children.iter().map(|c| c.tokens).sum();
scored, unscored); views.push(SectionView {
sections.insert(sections.len() - 1, mem_section); // before conversation name: format!("Memory nodes ({} scored, {} unscored)", scored, unscored),
tokens: mem_tokens,
content: String::new(),
children: mem_children,
});
} }
sections // Conversation — each entry as a child
views.push(section_to_view(&ag.context.conversation));
views
} }
} }
@ -70,7 +81,7 @@ impl ScreenView for ConsciousScreen {
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; }
let context_state = self.read_context_sections(); let context_state = self.read_context_views();
self.tree.handle_nav(key.code, &context_state, area.height); self.tree.handle_nav(key.code, &context_state, area.height);
} }
} }
@ -95,9 +106,9 @@ impl ScreenView for ConsciousScreen {
if !app.status.context_budget.is_empty() { if !app.status.context_budget.is_empty() {
lines.push(Line::raw(format!(" Budget: {}", app.status.context_budget))); lines.push(Line::raw(format!(" Budget: {}", app.status.context_budget)));
} }
let context_state = self.read_context_sections(); let context_state = self.read_context_views();
if !context_state.is_empty() { if !context_state.is_empty() {
let total: usize = context_state.iter().map(|s| s.tokens()).sum(); let total: usize = context_state.iter().map(|s| s.tokens).sum();
lines.push(Line::raw("")); lines.push(Line::raw(""));
lines.push(Line::styled( lines.push(Line::styled(
" (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)", " (↑/↓ select, →/Enter expand, ← collapse, PgUp/PgDn scroll)",

View file

@ -15,9 +15,7 @@ use ratatui::{
}; };
use super::{App, ScreenView, screen_legend}; use super::{App, ScreenView, screen_legend};
use super::widgets::{SectionTree, pane_block_focused, render_scrollable, tree_legend, format_age, format_ts_age}; use super::widgets::{SectionTree, SectionView, section_to_view, pane_block_focused, render_scrollable, tree_legend, format_age, format_ts_age};
use crate::agent::context::{ContextSection, ContextEntry, ConversationEntry};
use crate::agent::api::Message;
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
enum Pane { Agents, Outputs, History, Context } enum Pane { Agents, Outputs, History, Context }
@ -130,23 +128,22 @@ impl SubconsciousScreen {
self.history_scroll = 0; self.history_scroll = 0;
} }
fn output_sections(&self, app: &App) -> Vec<ContextSection> { fn output_sections(&self, app: &App) -> Vec<SectionView> {
let snap = match app.agent_state.get(self.selected()) { let snap = match app.agent_state.get(self.selected()) {
Some(s) => s, Some(s) => s,
None => return Vec::new(), None => return Vec::new(),
}; };
snap.state.iter().map(|(key, val)| { snap.state.iter().map(|(key, val)| {
let mut section = ContextSection::new(key.clone()); SectionView {
section.push(ContextEntry { name: key.clone(),
entry: ConversationEntry::Message(Message::user(val)),
tokens: 0, tokens: 0,
timestamp: None, content: val.clone(),
}); children: Vec::new(),
section }
}).collect() }).collect()
} }
fn read_sections(&self, app: &App) -> Vec<ContextSection> { fn read_sections(&self, app: &App) -> Vec<SectionView> {
let snap = match app.agent_state.get(self.selected()) { let snap = match app.agent_state.get(self.selected()) {
Some(s) => s, Some(s) => s,
None => return Vec::new(), None => return Vec::new(),
@ -154,13 +151,12 @@ impl SubconsciousScreen {
snap.forked_agent.as_ref() snap.forked_agent.as_ref()
.and_then(|agent| agent.try_lock().ok()) .and_then(|agent| agent.try_lock().ok())
.map(|ag| { .map(|ag| {
// Build a single section from the forked conversation entries let conv = ag.context.conversation.clone();
let entries = ag.conversation_entries_from(snap.fork_point); // Only show entries from fork point onward
let mut section = ContextSection::new("Conversation"); let mut view = section_to_view(&conv);
for e in entries { let fork = snap.fork_point.min(view.children.len());
section.push(e.clone()); view.children = view.children.split_off(fork);
} vec![view]
vec![section]
}) })
.unwrap_or_default() .unwrap_or_default()
} }
@ -281,7 +277,7 @@ impl SubconsciousScreen {
&self, &self,
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
sections: &[ContextSection], sections: &[SectionView],
app: &App, app: &App,
) { ) {
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();

View file

@ -10,6 +10,40 @@ use ratatui::{
}; };
use crate::agent::context::ContextSection; use crate::agent::context::ContextSection;
/// UI-only tree node for the section tree display.
/// Built from ContextSection data; not used for budgeting.
#[derive(Debug, Clone)]
pub struct SectionView {
pub name: String,
pub tokens: usize,
pub content: String,
pub children: Vec<SectionView>,
}
/// Build a SectionView tree from a ContextSection.
/// Each entry becomes a child with label + expandable content.
pub fn section_to_view(section: &ContextSection) -> SectionView {
let children: Vec<SectionView> = section.entries().iter().map(|ce| {
let content = if ce.entry.is_log() {
String::new()
} else {
ce.entry.message().content_text().to_string()
};
SectionView {
name: ce.entry.label(),
tokens: ce.tokens,
content,
children: Vec::new(),
}
}).collect();
SectionView {
name: section.name.clone(),
tokens: section.tokens(),
content: String::new(),
children,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -98,30 +132,32 @@ impl SectionTree {
Self { selected: None, expanded: std::collections::HashSet::new(), scroll: 0 } Self { selected: None, expanded: std::collections::HashSet::new(), scroll: 0 }
} }
/// Total nodes in the tree (regardless of expand state). fn total_nodes(&self, sections: &[SectionView]) -> usize {
/// Each section is 1 node, each entry within is 1 node. fn count_all(s: &SectionView) -> usize {
fn total_nodes(&self, sections: &[ContextSection]) -> usize { 1 + s.children.iter().map(|c| count_all(c)).sum::<usize>()
sections.iter().map(|s| 1 + s.entries().len()).sum() }
sections.iter().map(|s| count_all(s)).sum()
} }
pub fn item_count(&self, sections: &[ContextSection]) -> usize { pub fn item_count(&self, sections: &[SectionView]) -> usize {
let mut idx = 0; fn count(section: &SectionView, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
let mut total = 0; let my_idx = *idx;
for section in sections { *idx += 1;
let my_idx = idx; let mut total = 1;
idx += 1; if expanded.contains(&my_idx) {
total += 1; for child in &section.children {
if self.expanded.contains(&my_idx) { total += count(child, expanded, idx);
total += section.entries().len();
idx += section.entries().len();
} }
} }
total 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], height: u16) { pub fn handle_nav(&mut self, code: KeyCode, sections: &[SectionView], height: u16) {
let item_count = self.item_count(sections); let item_count = self.item_count(sections);
let page = height.saturating_sub(2) as usize; // account for border let page = height.saturating_sub(2) as usize;
match code { match code {
KeyCode::Up => { KeyCode::Up => {
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1)); self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
@ -134,7 +170,7 @@ impl SectionTree {
let sel = self.selected.unwrap_or(0); let sel = self.selected.unwrap_or(0);
self.selected = Some(sel.saturating_sub(page)); self.selected = Some(sel.saturating_sub(page));
self.scroll = self.scroll.saturating_sub(page as u16); self.scroll = self.scroll.saturating_sub(page as u16);
return; // skip scroll_to_selected — we moved both together return;
} }
KeyCode::PageDown => { KeyCode::PageDown => {
let max = item_count.saturating_sub(1); let max = item_count.saturating_sub(1);
@ -160,14 +196,12 @@ impl SectionTree {
} }
} }
KeyCode::Char('e') => { KeyCode::Char('e') => {
// Expand all
let total = self.total_nodes(sections); let total = self.total_nodes(sections);
for i in 0..total { for i in 0..total {
self.expanded.insert(i); self.expanded.insert(i);
} }
} }
KeyCode::Char('c') => { KeyCode::Char('c') => {
// Collapse all
self.expanded.clear(); self.expanded.clear();
} }
_ => {} _ => {}
@ -178,7 +212,7 @@ impl SectionTree {
fn scroll_to_selected(&mut self, height: u16) { fn scroll_to_selected(&mut self, height: u16) {
if let Some(sel) = self.selected { if let Some(sel) = self.selected {
let sel_line = sel as u16; let sel_line = sel as u16;
let visible = height.saturating_sub(2); // border let visible = height.saturating_sub(2);
if sel_line < self.scroll { if sel_line < self.scroll {
self.scroll = sel_line; self.scroll = sel_line;
} else if sel_line >= self.scroll + visible { } else if sel_line >= self.scroll + visible {
@ -187,27 +221,30 @@ impl SectionTree {
} }
} }
pub fn render_sections(&self, sections: &[ContextSection], lines: &mut Vec<Line>) { pub fn render_sections(&self, sections: &[SectionView], lines: &mut Vec<Line>) {
let mut idx = 0; let mut idx = 0;
for section in sections { for section in sections {
self.render_one(section, lines, &mut idx); self.render_one(section, 0, lines, &mut idx);
} }
} }
fn render_one( fn render_one(
&self, &self,
section: &ContextSection, section: &SectionView,
depth: usize,
lines: &mut Vec<Line>, lines: &mut Vec<Line>,
idx: &mut usize, idx: &mut usize,
) { ) {
let my_idx = *idx; let my_idx = *idx;
let selected = self.selected == Some(my_idx); let selected = self.selected == Some(my_idx);
let expanded = self.expanded.contains(&my_idx); let expanded = self.expanded.contains(&my_idx);
let expandable = !section.is_empty(); 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 marker = if !expandable { " " } else if expanded { "" } else { "" };
let label = format!(" {} {:30} {:>6} tokens", let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens);
marker, format!("{} ({})", section.name, section.len()), section.tokens());
let style = if selected { let style = if selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else { } else {
@ -217,44 +254,27 @@ impl SectionTree {
*idx += 1; *idx += 1;
if expanded { if expanded {
for ce in section.entries() { if has_children {
let entry_selected = self.selected == Some(*idx); for child in &section.children {
let entry_expanded = self.expanded.contains(idx); self.render_one(child, depth + 1, lines, idx);
let text = if ce.entry.is_log() { }
String::new() } else if has_content {
} else { let content_indent = format!("{}", " ".repeat(depth + 1));
ce.entry.message().content_text().to_string() let content_lines: Vec<&str> = section.content.lines().collect();
};
let has_content = text.len() > 0;
let entry_marker = if has_content {
if entry_expanded { "" } else { "" }
} else { " " };
let entry_label = format!(" {} {:>6} {}", entry_marker, ce.tokens, ce.entry.label());
let style = if entry_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
lines.push(Line::styled(entry_label, style));
*idx += 1;
if entry_expanded {
let content_lines: Vec<&str> = text.lines().collect();
let show = content_lines.len().min(50); let show = content_lines.len().min(50);
for line in &content_lines[..show] { for line in &content_lines[..show] {
lines.push(Line::styled( lines.push(Line::styled(
format!("{}", line), format!("{}{}", content_indent, line),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} }
if content_lines.len() > 50 { if content_lines.len() > 50 {
lines.push(Line::styled( lines.push(Line::styled(
format!(" ... ({} more lines)", content_lines.len() - 50), format!("{}... ({} more lines)", content_indent, content_lines.len() - 50),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} }
} }
} }
} }
}
} }