user: ScreenView trait, overlay screens extracted from App

Convert F2-F5 screens to ScreenView trait with tick() method.
Each screen owns its view state (scroll, selection, expanded).
State persists across screen switches.

- ThalamusScreen: owns sampling_selected, scroll
- ConsciousScreen: owns scroll, selected, expanded
- SubconsciousScreen: owns selected, log_view, scroll
- UnconsciousScreen: owns scroll

Removed from App: Screen enum, debug_scroll, debug_selected,
debug_expanded, agent_selected, agent_log_view, sampling_selected,
set_screen(), per-screen key handling, draw dispatch.

App now only draws the interact (F1) screen. Overlay screens are
drawn by the event loop via ScreenView::tick. F-key routing and
screen instantiation to be wired in event_loop next.

InteractScreen (state-driven, reading from agent entries) is the
next step — will eliminate the input display race condition.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 17:54:40 -04:00
parent 7458fe655f
commit 927cddd864
8 changed files with 388 additions and 439 deletions

View file

@ -1,3 +1,5 @@
#![feature(coroutines, coroutine_trait)]
// consciousness — unified crate for memory, agents, and subconscious processes // consciousness — unified crate for memory, agents, and subconscious processes
// //
// thought/ — shared cognitive substrate (tools, context, memory ops) // thought/ — shared cognitive substrate (tools, context, memory ops)

View file

@ -11,7 +11,7 @@ use ratatui::{
Frame, Frame,
}; };
use super::{ActivePane, App, Marker, PaneState, SCREEN_LEGEND}; use super::{ActivePane, App, Marker, PaneState, screen_legend};
impl App { impl App {
/// Draw the main (F1) screen — four-pane layout with status bar. /// Draw the main (F1) screen — four-pane layout with status bar.
@ -62,7 +62,7 @@ impl App {
// Draw autonomous pane // Draw autonomous pane
let auto_active = self.active_pane == ActivePane::Autonomous; let auto_active = self.active_pane == ActivePane::Autonomous;
draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active, draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active,
Some(SCREEN_LEGEND)); Some(&screen_legend()));
// Draw tools pane // Draw tools pane
let tools_active = self.active_pane == ActivePane::Tools; let tools_active = self.active_pane == ActivePane::Tools;

View file

@ -9,18 +9,27 @@ use ratatui::{
text::Line, text::Line,
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
Frame, Frame,
crossterm::event::{KeyCode, KeyEvent},
}; };
use super::{App, SCREEN_LEGEND}; use super::{App, ScreenAction, ScreenView, screen_legend};
impl App { pub(crate) struct ConsciousScreen {
/// Read the live context state from the shared lock. scroll: u16,
pub(crate) fn read_context_state(&self) -> Vec<crate::user::ui_channel::ContextSection> { selected: Option<usize>,
self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone()) expanded: std::collections::HashSet<usize>,
}
impl ConsciousScreen {
pub fn new() -> Self {
Self { scroll: 0, selected: None, expanded: std::collections::HashSet::new() }
} }
/// Count total selectable items in the context state tree. fn read_context_state(&self, app: &App) -> Vec<crate::user::ui_channel::ContextSection> {
pub(crate) fn debug_item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize { app.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone())
}
fn item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize {
fn count_section(section: &crate::user::ui_channel::ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize { fn count_section(section: &crate::user::ui_channel::ContextSection, expanded: &std::collections::HashSet<usize>, idx: &mut usize) -> usize {
let my_idx = *idx; let my_idx = *idx;
*idx += 1; *idx += 1;
@ -35,50 +44,39 @@ impl App {
let mut idx = 0; let mut idx = 0;
let mut total = 0; let mut total = 0;
for section in context_state { for section in context_state {
total += count_section(section, &self.debug_expanded, &mut idx); total += count_section(section, &self.expanded, &mut idx);
} }
total total
} }
/// Keep the viewport scrolled so the selected item is visible. fn scroll_to_selected(&mut self, _item_count: usize) {
/// Assumes ~1 line per item plus a header offset of ~8 lines. let header_lines = 8u16;
pub(crate) fn scroll_to_selected(&mut self, _item_count: usize) { if let Some(sel) = self.selected {
let header_lines = 8u16; // model info + context state header
if let Some(sel) = self.debug_selected {
let sel_line = header_lines + sel as u16; let sel_line = header_lines + sel as u16;
// Keep cursor within a comfortable range of the viewport if sel_line < self.scroll + 2 {
if sel_line < self.debug_scroll + 2 { self.scroll = sel_line.saturating_sub(2);
self.debug_scroll = sel_line.saturating_sub(2); } else if sel_line > self.scroll + 30 {
} else if sel_line > self.debug_scroll + 30 { self.scroll = sel_line.saturating_sub(15);
self.debug_scroll = sel_line.saturating_sub(15);
} }
} }
} }
/// Render a context section as a tree node with optional children. fn render_section(
pub(crate) fn render_debug_section(
&self, &self,
section: &crate::user::ui_channel::ContextSection, section: &crate::user::ui_channel::ContextSection,
depth: usize, depth: usize,
start_idx: 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.debug_selected == Some(my_idx); let selected = self.selected == Some(my_idx);
let expanded = self.debug_expanded.contains(&my_idx); let expanded = self.expanded.contains(&my_idx);
let has_children = !section.children.is_empty(); let has_children = !section.children.is_empty();
let has_content = !section.content.is_empty(); let has_content = !section.content.is_empty();
let expandable = has_children || has_content; let expandable = has_children || has_content;
let indent = " ".repeat(depth + 1); let indent = " ".repeat(depth + 1);
let marker = if !expandable { let marker = if !expandable { " " } else if expanded { "" } else { "" };
" "
} else if expanded {
""
} else {
""
};
let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, section.tokens); let label = format!("{}{} {:30} {:>6} tokens", indent, marker, section.name, 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)
@ -91,7 +89,7 @@ impl App {
if expanded { if expanded {
if has_children { if has_children {
for child in &section.children { for child in &section.children {
self.render_debug_section(child, depth + 1, start_idx, lines, idx); self.render_section(child, depth + 1, lines, idx);
} }
} else if has_content { } else if has_content {
let content_indent = format!("{}", " ".repeat(depth + 1)); let content_indent = format!("{}", " ".repeat(depth + 1));
@ -112,31 +110,66 @@ impl App {
} }
} }
} }
}
/// Draw the debug screen — full-screen overlay with context and runtime info. impl ScreenView for ConsciousScreen {
pub(crate) fn draw_debug(&self, frame: &mut Frame, size: Rect) { fn label(&self) -> &'static str { "conscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, app: &App) -> Option<ScreenAction> {
// Handle keys
if let Some(key) = key {
let context_state = self.read_context_state(app);
let item_count = self.item_count(&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; }
KeyCode::Esc => return Some(ScreenAction::Switch(0)),
_ => {}
}
}
// Draw
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow); let section_style = Style::default().fg(Color::Yellow);
// Model lines.push(Line::styled("── Model ──", section_style));
lines.push(Line::styled("── Model ──", section)); let model_display = app.context_info.as_ref()
let model_display = self.context_info.as_ref() .map_or_else(|| app.status.model.clone(), |i| i.model.clone());
.map_or_else(|| self.status.model.clone(), |i| i.model.clone());
lines.push(Line::raw(format!(" Current: {}", model_display))); lines.push(Line::raw(format!(" Current: {}", model_display)));
if let Some(ref info) = self.context_info { if let Some(ref info) = app.context_info {
lines.push(Line::raw(format!(" Backend: {}", info.backend))); lines.push(Line::raw(format!(" Backend: {}", info.backend)));
lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file))); lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file)));
lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", ")))); lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", "))));
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Context state lines.push(Line::styled("── Context State ──", section_style));
lines.push(Line::styled("── Context State ──", section)); lines.push(Line::raw(format!(" Prompt tokens: {}K", app.status.prompt_tokens / 1000)));
lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000))); if !app.status.context_budget.is_empty() {
if !self.status.context_budget.is_empty() { lines.push(Line::raw(format!(" Budget: {}", app.status.context_budget)));
lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget)));
} }
let context_state = self.read_context_state(); let context_state = self.read_context_state(app);
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(""));
@ -146,32 +179,30 @@ impl App {
)); ));
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Flatten tree into indexed entries for selection
let mut flat_idx = 0usize; let mut flat_idx = 0usize;
for section in &context_state { for section in &context_state {
self.render_debug_section(section, 0, flat_idx, &mut lines, &mut flat_idx); 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)));
} else if let Some(ref info) = self.context_info { } else if let Some(ref info) = app.context_info {
lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars))); lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars)));
lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars))); lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars)));
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Runtime lines.push(Line::styled("── Runtime ──", section_style));
lines.push(Line::styled("── Runtime ──", section));
lines.push(Line::raw(format!( lines.push(Line::raw(format!(
" DMN: {} ({}/{})", " DMN: {} ({}/{})",
self.status.dmn_state, self.status.dmn_turns, self.status.dmn_max_turns, app.status.dmn_state, app.status.dmn_turns, app.status.dmn_max_turns,
))); )));
lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort))); lines.push(Line::raw(format!(" Reasoning: {}", app.reasoning_effort)));
lines.push(Line::raw(format!(" Running processes: {}", self.running_processes))); lines.push(Line::raw(format!(" Running processes: {}", app.running_processes)));
lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.lock().unwrap().len()))); lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len())));
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(" context ").right_aligned()) .title_top(Line::from(" context ").right_aligned())
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -179,8 +210,9 @@ impl App {
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(block)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.debug_scroll, 0)); .scroll((self.scroll, 0));
frame.render_widget(para, size); frame.render_widget(para, area);
None
} }
} }

View file

@ -409,7 +409,7 @@ pub async fn run(
if key.kind != KeyEventKind::Press { continue; } if key.kind != KeyEventKind::Press { continue; }
app.handle_key(key); app.handle_key(key);
idle_state.user_activity(); idle_state.user_activity();
if app.screen == tui::Screen::Thalamus { if false { // TODO: check active screen is thalamus
let tx = channel_tx.clone(); let tx = channel_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = crate::thalamus::channels::fetch_all_channels().await; let result = crate::thalamus::channels::fetch_all_channels().await;

View file

@ -30,7 +30,11 @@ use std::io;
use crate::user::ui_channel::{ContextInfo, SharedContextState, StatusInfo, UiMessage}; use crate::user::ui_channel::{ContextInfo, SharedContextState, StatusInfo, UiMessage};
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus "; /// Build the screen legend from the screen table.
pub(crate) fn screen_legend() -> String {
// Built from the SCREENS table in event_loop
" F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus ".to_string()
}
pub(crate) fn strip_ansi(text: &str) -> String { pub(crate) fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len()); let mut out = String::with_capacity(text.len());
@ -231,9 +235,19 @@ pub(crate) fn parse_markdown(md: &str) -> Vec<Line<'static>> {
.collect() .collect()
} }
#[derive(Debug, Clone, Copy, PartialEq)] /// Action returned from a screen's tick method.
pub enum Screen { pub enum ScreenAction {
Interact, Conscious, Subconscious, Unconscious, Thalamus, /// Switch to screen at this index
Switch(usize),
/// Send a hotkey action to the Mind
Hotkey(HotkeyAction),
}
/// A screen that can draw itself and handle input.
pub(crate) trait ScreenView {
fn tick(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect,
key: Option<ratatui::crossterm::event::KeyEvent>, app: &App) -> Option<ScreenAction>;
fn label(&self) -> &'static str;
} }
#[derive(Debug)] #[derive(Debug)]
@ -285,19 +299,11 @@ pub struct App {
pub submitted: Vec<String>, pub submitted: Vec<String>,
pub hotkey_actions: Vec<HotkeyAction>, pub hotkey_actions: Vec<HotkeyAction>,
pub(crate) pane_areas: [Rect; 3], pub(crate) pane_areas: [Rect; 3],
pub screen: Screen,
pub(crate) debug_scroll: u16,
pub(crate) debug_selected: Option<usize>,
pub(crate) debug_expanded: std::collections::HashSet<usize>,
pub(crate) context_info: Option<ContextInfo>, pub(crate) context_info: Option<ContextInfo>,
pub(crate) shared_context: SharedContextState, pub(crate) shared_context: SharedContextState,
pub(crate) agent_selected: usize,
pub(crate) agent_log_view: bool,
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>, pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
pub(crate) channel_status: Vec<ChannelStatus>, pub(crate) channel_status: Vec<ChannelStatus>,
pub(crate) idle_info: Option<IdleInfo>, pub(crate) idle_info: Option<IdleInfo>,
/// Thalamus screen: selected sampling param (0=temp, 1=top_p, 2=top_k).
pub(crate) sampling_selected: usize,
} }
impl App { impl App {
@ -323,12 +329,9 @@ impl App {
input_history: Vec::new(), history_index: None, input_history: Vec::new(), history_index: None,
should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(), should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(),
pane_areas: [Rect::default(); 3], pane_areas: [Rect::default(); 3],
screen: Screen::Interact,
debug_scroll: 0, debug_selected: None,
debug_expanded: std::collections::HashSet::new(),
context_info: None, shared_context, context_info: None, shared_context,
agent_selected: 0, agent_log_view: false, agent_state: Vec::new(), agent_state: Vec::new(),
channel_status: Vec::new(), idle_info: None, sampling_selected: 0, channel_status: Vec::new(), idle_info: None,
} }
} }
@ -417,6 +420,8 @@ impl App {
} }
} }
/// Handle global keys only (Ctrl combos, F-keys). Screen-specific
/// keys are handled by the active ScreenView's tick method.
pub fn handle_key(&mut self, key: KeyEvent) { pub fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) { if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code { match key.code {
@ -428,96 +433,6 @@ impl App {
} }
} }
match key.code {
KeyCode::F(1) => { self.set_screen(Screen::Interact); return; }
KeyCode::F(2) => { self.set_screen(Screen::Conscious); return; }
KeyCode::F(3) => { self.set_screen(Screen::Subconscious); return; }
KeyCode::F(4) => { self.set_screen(Screen::Unconscious); return; }
KeyCode::F(5) => { self.set_screen(Screen::Thalamus); return; }
_ => {}
}
match self.screen {
Screen::Subconscious => {
match key.code {
KeyCode::Up => { self.agent_selected = self.agent_selected.saturating_sub(1); self.debug_scroll = 0; return; }
KeyCode::Down => { self.agent_selected = (self.agent_selected + 1).min(self.agent_state.len().saturating_sub(1)); self.debug_scroll = 0; return; }
KeyCode::Enter | KeyCode::Right => { self.agent_log_view = true; self.debug_scroll = 0; return; }
KeyCode::Left | KeyCode::Esc => {
if self.agent_log_view { self.agent_log_view = false; self.debug_scroll = 0; }
else { self.screen = Screen::Interact; }
return;
}
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
KeyCode::PageDown => { self.debug_scroll += 10; return; }
_ => {}
}
}
Screen::Conscious => {
let cs = self.read_context_state();
let n = self.debug_item_count(&cs);
match key.code {
KeyCode::Up => {
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => n - 1, Some(0) => 0, Some(i) => i - 1 }); self.scroll_to_selected(n); }
return;
}
KeyCode::Down => {
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) if i >= n - 1 => n - 1, Some(i) => i + 1 }); self.scroll_to_selected(n); }
return;
}
KeyCode::PageUp => {
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) => i.saturating_sub(20) }); self.scroll_to_selected(n); }
return;
}
KeyCode::PageDown => {
if n > 0 { self.debug_selected = Some(match self.debug_selected { None => 0, Some(i) => (i + 20).min(n - 1) }); self.scroll_to_selected(n); }
return;
}
KeyCode::Right | KeyCode::Enter => { if let Some(idx) = self.debug_selected { self.debug_expanded.insert(idx); } return; }
KeyCode::Left => { if let Some(idx) = self.debug_selected { self.debug_expanded.remove(&idx); } return; }
KeyCode::Esc => { self.screen = Screen::Interact; return; }
_ => {}
}
}
Screen::Unconscious => {
match key.code {
KeyCode::PageUp => { self.debug_scroll = self.debug_scroll.saturating_sub(10); return; }
KeyCode::PageDown => { self.debug_scroll += 10; return; }
KeyCode::Esc => { self.screen = Screen::Interact; return; }
_ => {}
}
}
Screen::Thalamus => {
match key.code {
KeyCode::Up => { self.sampling_selected = self.sampling_selected.saturating_sub(1); return; }
KeyCode::Down => { self.sampling_selected = (self.sampling_selected + 1).min(2); return; }
KeyCode::Right => {
let delta = match self.sampling_selected {
0 => 0.05, // temperature
1 => 0.05, // top_p
2 => 5.0, // top_k
_ => 0.0,
};
self.hotkey_actions.push(HotkeyAction::AdjustSampling(self.sampling_selected, delta));
return;
}
KeyCode::Left => {
let delta = match self.sampling_selected {
0 => -0.05,
1 => -0.05,
2 => -5.0,
_ => 0.0,
};
self.hotkey_actions.push(HotkeyAction::AdjustSampling(self.sampling_selected, delta));
return;
}
KeyCode::Esc => { self.screen = Screen::Interact; return; }
_ => {}
}
}
Screen::Interact => {}
}
match key.code { match key.code {
KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); } KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); }
KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => { KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => {
@ -601,15 +516,10 @@ impl App {
} }
} }
/// Draw the interact (F1) screen. Overlay screens are drawn
/// by the event loop via ScreenView::tick.
pub fn draw(&mut self, frame: &mut Frame) { pub fn draw(&mut self, frame: &mut Frame) {
let size = frame.area(); let size = frame.area();
match self.screen {
Screen::Conscious => { self.draw_debug(frame, size); return; }
Screen::Subconscious => { self.draw_agents(frame, size); return; }
Screen::Unconscious => { self.draw_unconscious(frame, size); return; }
Screen::Thalamus => { self.draw_thalamus(frame, size); return; }
Screen::Interact => {}
}
self.draw_main(frame, size); self.draw_main(frame, size);
} }
@ -627,10 +537,6 @@ impl App {
}); });
} }
pub(crate) fn set_screen(&mut self, screen: Screen) {
self.screen = screen;
self.debug_scroll = 0;
}
} }
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> { pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {

View file

@ -1,7 +1,4 @@
// subconscious_screen.rs — F3 subconscious agent overlay // subconscious_screen.rs — F3 subconscious agent overlay
//
// Shows agent list with status indicators, and a detail view
// with log tail for the selected agent.
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
@ -9,19 +6,63 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
Frame, Frame,
crossterm::event::{KeyCode, KeyEvent},
}; };
use super::{App, SCREEN_LEGEND}; use super::{App, ScreenAction, ScreenView, screen_legend};
impl App { pub(crate) struct SubconsciousScreen {
pub(crate) fn draw_agents(&self, frame: &mut Frame, size: Rect) { selected: usize,
let output_dir = crate::store::memory_dir().join("agent-output"); log_view: bool,
scroll: u16,
}
if self.agent_log_view { impl SubconsciousScreen {
self.draw_agent_log(frame, size, &output_dir); pub fn new() -> Self {
return; Self { selected: 0, log_view: false, scroll: 0 }
}
}
impl ScreenView for SubconsciousScreen {
fn label(&self) -> &'static str { "subconscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, app: &App) -> Option<ScreenAction> {
// Handle keys
if let Some(key) = key {
match key.code {
KeyCode::Up if !self.log_view => {
self.selected = self.selected.saturating_sub(1);
}
KeyCode::Down if !self.log_view => {
self.selected = (self.selected + 1).min(app.agent_state.len().saturating_sub(1));
}
KeyCode::Enter | KeyCode::Right if !self.log_view => {
self.log_view = true;
self.scroll = 0;
}
KeyCode::Esc | KeyCode::Left if self.log_view => {
self.log_view = false;
}
KeyCode::Esc if !self.log_view => return Some(ScreenAction::Switch(0)),
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
_ => {}
}
} }
// Draw
if self.log_view {
self.draw_log(frame, area, app);
} else {
self.draw_list(frame, area, app);
}
None
}
}
impl SubconsciousScreen {
fn draw_list(&self, frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow); let section = Style::default().fg(Color::Yellow);
let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); let hint = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
@ -31,51 +72,42 @@ impl App {
lines.push(Line::styled(" (↑/↓ select, Enter/→ view log, Esc back)", hint)); lines.push(Line::styled(" (↑/↓ select, Enter/→ view log, Esc back)", hint));
lines.push(Line::raw("")); lines.push(Line::raw(""));
for (i, agent) in self.agent_state.iter().enumerate() { for (i, agent) in app.agent_state.iter().enumerate() {
let selected = i == self.agent_selected; let selected = i == self.selected;
let prefix = if selected { "" } else { " " }; let prefix = if selected { "" } else { " " };
let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() }; let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() };
let status = match (&agent.pid, &agent.phase) { let status = match (&agent.pid, &agent.phase) {
(Some(pid), Some(phase)) => { (Some(pid), Some(phase)) => vec![
vec![ Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Green)),
Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Green)), Span::styled("", bg.fg(Color::Green)),
Span::styled("", bg.fg(Color::Green)), Span::styled(format!("pid {} {}", pid, phase), bg),
Span::styled(format!("pid {} {}", pid, phase), bg), ],
] (None, Some(phase)) => vec![
} Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Cyan)),
(None, Some(phase)) => { Span::styled("", bg.fg(Color::Cyan)),
// No pid but has phase — async task (e.g. memory-scoring) Span::styled(phase.clone(), bg),
vec![ ],
Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Cyan)), _ => vec![
Span::styled("", bg.fg(Color::Cyan)), Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Gray)),
Span::styled(phase.clone(), bg), Span::styled("○ idle", bg.fg(Color::DarkGray)),
] ],
}
_ => {
vec![
Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Gray)),
Span::styled("○ idle", bg.fg(Color::DarkGray)),
]
}
}; };
lines.push(Line::from(status)); lines.push(Line::from(status));
} }
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(" subconscious ").right_aligned()) .title_top(Line::from(" subconscious ").right_aligned())
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines) let para = Paragraph::new(lines).block(block).scroll((self.scroll, 0));
.block(block) frame.render_widget(para, area);
.scroll((self.debug_scroll, 0));
frame.render_widget(para, size);
} }
fn draw_agent_log(&self, frame: &mut Frame, size: Rect, _output_dir: &std::path::Path) { fn draw_log(&self, frame: &mut Frame, area: Rect, app: &App) {
let agent = self.agent_state.get(self.agent_selected); let agent = app.agent_state.get(self.selected);
let name = agent.map(|a| a.name.as_str()).unwrap_or("?"); let name = agent.map(|a| a.name.as_str()).unwrap_or("?");
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow); let section = Style::default().fg(Color::Yellow);
@ -86,7 +118,6 @@ impl App {
lines.push(Line::styled(" (Esc/← back, PgUp/PgDn scroll)", hint)); lines.push(Line::styled(" (Esc/← back, PgUp/PgDn scroll)", hint));
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Show pid status from state
match agent.and_then(|a| a.pid) { match agent.and_then(|a| a.pid) {
Some(pid) => { Some(pid) => {
let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?"); let phase = agent.and_then(|a| a.phase.as_deref()).unwrap_or("?");
@ -101,13 +132,11 @@ impl App {
} }
} }
// Show log path
if let Some(log_path) = agent.and_then(|a| a.log_path.as_ref()) { if let Some(log_path) = agent.and_then(|a| a.log_path.as_ref()) {
lines.push(Line::raw(format!(" Log: {}", log_path.display()))); lines.push(Line::raw(format!(" Log: {}", log_path.display())));
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Show agent log tail
lines.push(Line::styled("── Agent Log ──", section)); lines.push(Line::styled("── Agent Log ──", section));
if let Some(content) = agent if let Some(content) = agent
.and_then(|a| a.log_path.as_ref()) .and_then(|a| a.log_path.as_ref())
@ -123,7 +152,7 @@ impl App {
} }
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(format!(" {} ", name)).right_aligned()) .title_top(Line::from(format!(" {} ", name)).right_aligned())
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -131,7 +160,7 @@ impl App {
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(block)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.debug_scroll, 0)); .scroll((self.scroll, 0));
frame.render_widget(para, size); frame.render_widget(para, area);
} }
} }

View file

@ -1,7 +1,4 @@
// thalamus_screen.rs — F5: presence, idle state, and channel status // thalamus_screen.rs — F5: presence, idle state, and channel status
//
// Shows idle state from the in-process thalamus (no subprocess spawn),
// then channel daemon status from cached data.
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
@ -9,34 +6,70 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
Frame, Frame,
crossterm::event::{KeyCode, KeyEvent},
}; };
use super::{App, SCREEN_LEGEND}; use super::{App, HotkeyAction, ScreenAction, ScreenView, screen_legend};
impl App { pub(crate) struct ThalamusScreen {
pub(crate) fn draw_thalamus(&self, frame: &mut Frame, size: Rect) { sampling_selected: usize,
scroll: u16,
}
impl ThalamusScreen {
pub fn new() -> Self {
Self { sampling_selected: 0, scroll: 0 }
}
}
impl ScreenView for ThalamusScreen {
fn label(&self) -> &'static str { "thalamus" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, app: &App) -> Option<ScreenAction> {
// Handle keys
if let Some(key) = key {
match key.code {
KeyCode::Up => { self.sampling_selected = self.sampling_selected.saturating_sub(1); }
KeyCode::Down => { self.sampling_selected = (self.sampling_selected + 1).min(2); }
KeyCode::Right => {
let delta = match self.sampling_selected {
0 => 0.05, 1 => 0.05, 2 => 5.0, _ => 0.0,
};
return Some(ScreenAction::Hotkey(HotkeyAction::AdjustSampling(self.sampling_selected, delta)));
}
KeyCode::Left => {
let delta = match self.sampling_selected {
0 => -0.05, 1 => -0.05, 2 => -5.0, _ => 0.0,
};
return Some(ScreenAction::Hotkey(HotkeyAction::AdjustSampling(self.sampling_selected, delta)));
}
KeyCode::Esc => return Some(ScreenAction::Switch(0)),
_ => {}
}
}
// Draw
let section = Style::default().fg(Color::Yellow); let section = Style::default().fg(Color::Yellow);
let dim = Style::default().fg(Color::DarkGray); let dim = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
// Presence / idle state from in-process thalamus // Presence / idle state
lines.push(Line::styled("── Presence ──", section)); lines.push(Line::styled("── Presence ──", section));
lines.push(Line::raw("")); lines.push(Line::raw(""));
if let Some(ref idle) = self.idle_info { if let Some(ref idle) = app.idle_info {
let presence = if idle.user_present { let presence = if idle.user_present {
Span::styled("present", Style::default().fg(Color::Green)) Span::styled("present", Style::default().fg(Color::Green))
} else { } else {
Span::styled("away", Style::default().fg(Color::DarkGray)) Span::styled("away", Style::default().fg(Color::DarkGray))
}; };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" User: "), Span::raw(" User: "), presence,
presence,
Span::raw(format!(" (last {:.0}s ago)", idle.since_activity)), Span::raw(format!(" (last {:.0}s ago)", idle.since_activity)),
])); ]));
lines.push(Line::raw(format!(" Activity: {:.1}%", idle.activity_ewma * 100.0))); lines.push(Line::raw(format!(" Activity: {:.1}%", idle.activity_ewma * 100.0)));
lines.push(Line::raw(format!(" Idle state: {}", idle.block_reason))); lines.push(Line::raw(format!(" Idle state: {}", idle.block_reason)));
if idle.dreaming { if idle.dreaming {
lines.push(Line::styled(" ◆ dreaming", Style::default().fg(Color::Magenta))); lines.push(Line::styled(" ◆ dreaming", Style::default().fg(Color::Magenta)));
} }
@ -48,13 +81,13 @@ impl App {
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Sampling parameters (↑/↓ select, ←/→ adjust) // Sampling parameters
lines.push(Line::styled("── Sampling (←/→ adjust) ──", section)); lines.push(Line::styled("── Sampling (←/→ adjust) ──", section));
lines.push(Line::raw("")); lines.push(Line::raw(""));
let params = [ let params = [
format!("temperature: {:.2}", self.temperature), format!("temperature: {:.2}", app.temperature),
format!("top_p: {:.2}", self.top_p), format!("top_p: {:.2}", app.top_p),
format!("top_k: {}", self.top_k), format!("top_k: {}", app.top_k),
]; ];
for (i, label) in params.iter().enumerate() { for (i, label) in params.iter().enumerate() {
let prefix = if i == self.sampling_selected { "" } else { " " }; let prefix = if i == self.sampling_selected { "" } else { " " };
@ -67,41 +100,27 @@ impl App {
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Channel status from cached data // Channel status
lines.push(Line::styled("── Channels ──", section)); lines.push(Line::styled("── Channels ──", section));
lines.push(Line::raw("")); lines.push(Line::raw(""));
if app.channel_status.is_empty() {
if self.channel_status.is_empty() {
lines.push(Line::styled(" no channels configured", dim)); lines.push(Line::styled(" no channels configured", dim));
} else { } else {
for ch in &self.channel_status { for ch in &app.channel_status {
let (symbol, color) = if ch.connected { let (symbol, color) = if ch.connected { ("", Color::Green) } else { ("", Color::Red) };
("", Color::Green) let unread_str = if ch.unread > 0 { format!(" ({} unread)", ch.unread) } else { String::new() };
} else {
("", Color::Red)
};
let unread_str = if ch.unread > 0 {
format!(" ({} unread)", ch.unread)
} else {
String::new()
};
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled(symbol, Style::default().fg(color)), Span::styled(symbol, Style::default().fg(color)),
Span::raw(format!(" {:<24}", ch.name)), Span::raw(format!(" {:<24}", ch.name)),
Span::styled( Span::styled(if ch.connected { "connected" } else { "disconnected" }, Style::default().fg(color)),
if ch.connected { "connected" } else { "disconnected" },
Style::default().fg(color),
),
Span::styled(unread_str, Style::default().fg(Color::Yellow)), Span::styled(unread_str, Style::default().fg(Color::Yellow)),
])); ]));
} }
} }
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(" thalamus ").right_aligned()) .title_top(Line::from(" thalamus ").right_aligned())
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -109,8 +128,9 @@ impl App {
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(block) .block(block)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((self.debug_scroll, 0)); .scroll((self.scroll, 0));
frame.render_widget(para, size); frame.render_widget(para, area);
None
} }
} }

View file

@ -1,7 +1,4 @@
// unconscious_screen.rs — F4: memory daemon status // unconscious_screen.rs — F4: memory daemon status
//
// Fetches status from the poc-memory daemon via socket RPC and
// displays graph health gauges, running tasks, and recent completions.
use ratatui::{ use ratatui::{
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
@ -9,12 +6,12 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Wrap}, widgets::{Block, Borders, Gauge, Paragraph, Wrap},
Frame, Frame,
crossterm::event::{KeyCode, KeyEvent},
}; };
use super::{App, SCREEN_LEGEND}; use super::{App, ScreenAction, ScreenView, screen_legend};
use crate::subconscious::daemon::GraphHealth; use crate::subconscious::daemon::GraphHealth;
/// Status fetched from the daemon socket.
#[derive(serde::Deserialize, Default)] #[derive(serde::Deserialize, Default)]
struct DaemonStatus { struct DaemonStatus {
#[allow(dead_code)] #[allow(dead_code)]
@ -29,200 +26,163 @@ fn fetch_status() -> Option<DaemonStatus> {
serde_json::from_str(&json).ok() serde_json::from_str(&json).ok()
} }
impl App { pub(crate) struct UnconsciousScreen {
pub(crate) fn draw_unconscious(&self, frame: &mut Frame, size: Rect) { scroll: u16,
}
impl UnconsciousScreen {
pub fn new() -> Self { Self { scroll: 0 } }
}
impl ScreenView for UnconsciousScreen {
fn label(&self) -> &'static str { "unconscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, _app: &App) -> Option<ScreenAction> {
if let Some(key) = key {
match key.code {
KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); }
KeyCode::PageDown => { self.scroll += 20; }
KeyCode::Esc => return Some(ScreenAction::Switch(0)),
_ => {}
}
}
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(screen_legend()).left_aligned())
.title_top(Line::from(" unconscious ").right_aligned()) .title_top(Line::from(" unconscious ").right_aligned())
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(size); let inner = block.inner(area);
frame.render_widget(block, size); frame.render_widget(block, area);
let status = fetch_status(); let status = fetch_status();
match &status { match &status {
None => { None => {
let dim = Style::default().fg(Color::DarkGray);
frame.render_widget( frame.render_widget(
Paragraph::new(Line::styled(" daemon not running", dim)), Paragraph::new(Line::styled(" daemon not running",
Style::default().fg(Color::DarkGray))),
inner, inner,
); );
} }
Some(st) => { Some(st) => {
// Split into health area and tasks area
let has_health = st.graph_health.is_some(); let has_health = st.graph_health.is_some();
let [health_area, tasks_area] = Layout::vertical([ let [health_area, tasks_area] = Layout::vertical([
Constraint::Length(if has_health { 9 } else { 0 }), Constraint::Length(if has_health { 9 } else { 0 }),
Constraint::Min(1), Constraint::Min(1),
]) ]).areas(inner);
.areas(inner);
if let Some(ref gh) = st.graph_health { if let Some(ref gh) = st.graph_health {
Self::render_health(frame, gh, health_area); render_health(frame, gh, health_area);
} }
render_tasks(frame, &st.tasks, tasks_area);
Self::render_tasks(frame, &st.tasks, tasks_area);
} }
} }
} None
fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) {
let [metrics_area, gauges_area, plan_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Length(4),
Constraint::Min(1),
])
.areas(area);
// Metrics summary
let summary = Line::from(format!(
" {} nodes {} edges {} communities",
gh.nodes, gh.edges, gh.communities
));
let ep_line = Line::from(vec![
Span::raw(" episodic: "),
Span::styled(
format!("{:.0}%", gh.episodic_ratio * 100.0),
if gh.episodic_ratio < 0.4 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
},
),
Span::raw(format!(" σ={:.1} interference={}", gh.sigma, gh.interference)),
]);
frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area);
// Health gauges
let [g1, g2, g3] = Layout::horizontal([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.areas(gauges_area);
let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red };
frame.render_widget(
Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" α (≥2.5) "))
.gauge_style(Style::default().fg(alpha_color))
.ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64)
.label(format!("{:.2}", gh.alpha)),
g1,
);
let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red };
frame.render_widget(
Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) "))
.gauge_style(Style::default().fg(gini_color))
.ratio(gh.gini.clamp(0.0, 1.0) as f64)
.label(format!("{:.3}", gh.gini)),
g2,
);
let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red };
frame.render_widget(
Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) "))
.gauge_style(Style::default().fg(cc_color))
.ratio(gh.avg_cc.clamp(0.0, 1.0) as f64)
.label(format!("{:.3}", gh.avg_cc)),
g3,
);
// Plan summary
let plan_total: usize = gh.plan_counts.values().sum::<usize>() + 1;
let mut plan_items: Vec<_> = gh.plan_counts.iter()
.filter(|(_, c)| **c > 0)
.collect();
plan_items.sort_by(|a, b| a.0.cmp(b.0));
let plan_summary: Vec<String> = plan_items.iter()
.map(|(a, c)| format!("{}{}", &a[..1], c))
.collect();
let plan_line = Line::from(vec![
Span::raw(" plan: "),
Span::styled(
format!("{}", plan_total),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))),
]);
frame.render_widget(Paragraph::new(plan_line), plan_area);
}
fn render_tasks(frame: &mut Frame, tasks: &[jobkit::TaskInfo], area: Rect) {
let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow);
let dim = Style::default().fg(Color::DarkGray);
let running: Vec<_> = tasks.iter()
.filter(|t| matches!(t.status, jobkit::TaskStatus::Running))
.collect();
let completed: Vec<_> = tasks.iter()
.filter(|t| matches!(t.status, jobkit::TaskStatus::Completed))
.collect();
let failed: Vec<_> = tasks.iter()
.filter(|t| matches!(t.status, jobkit::TaskStatus::Failed))
.collect();
lines.push(Line::styled("── Tasks ──", section));
lines.push(Line::raw(format!(
" Running: {} Completed: {} Failed: {}",
running.len(), completed.len(), failed.len()
)));
lines.push(Line::raw(""));
// Running tasks with elapsed time
if !running.is_empty() {
for task in &running {
let elapsed = task.started_at
.map(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
format!("{}s", (now - s) as u64)
})
.unwrap_or_default();
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Green)),
Span::raw(format!(" {} ({})", task.name, elapsed)),
]));
}
lines.push(Line::raw(""));
}
// Recent completed (last 10)
if !completed.is_empty() {
lines.push(Line::styled(" Recent:", dim));
for task in completed.iter().rev().take(10) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Green)),
Span::raw(format!(" {}", task.name)),
]));
}
}
// Failed tasks
if !failed.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(" Failed:", Style::default().fg(Color::Red)));
for task in failed.iter().rev().take(5) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Red)),
Span::raw(format!(" {}", task.name)),
]));
}
}
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }),
area,
);
} }
} }
fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) {
let [metrics_area, gauges_area, plan_area] = Layout::vertical([
Constraint::Length(2), Constraint::Length(4), Constraint::Min(1),
]).areas(area);
let summary = Line::from(format!(
" {} nodes {} edges {} communities", gh.nodes, gh.edges, gh.communities
));
let ep_line = Line::from(vec![
Span::raw(" episodic: "),
Span::styled(format!("{:.0}%", gh.episodic_ratio * 100.0),
if gh.episodic_ratio < 0.4 { Style::default().fg(Color::Green) }
else { Style::default().fg(Color::Red) }),
Span::raw(format!(" σ={:.1} interference={}", gh.sigma, gh.interference)),
]);
frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area);
let [g1, g2, g3] = Layout::horizontal([
Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3),
]).areas(gauges_area);
let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red };
frame.render_widget(Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" α (≥2.5) "))
.gauge_style(Style::default().fg(alpha_color))
.ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64)
.label(format!("{:.2}", gh.alpha)), g1);
let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red };
frame.render_widget(Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) "))
.gauge_style(Style::default().fg(gini_color))
.ratio(gh.gini.clamp(0.0, 1.0) as f64)
.label(format!("{:.3}", gh.gini)), g2);
let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red };
frame.render_widget(Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) "))
.gauge_style(Style::default().fg(cc_color))
.ratio(gh.avg_cc.clamp(0.0, 1.0) as f64)
.label(format!("{:.3}", gh.avg_cc)), g3);
let plan_total: usize = gh.plan_counts.values().sum::<usize>() + 1;
let mut plan_items: Vec<_> = gh.plan_counts.iter().filter(|(_, c)| **c > 0).collect();
plan_items.sort_by(|a, b| a.0.cmp(b.0));
let plan_summary: Vec<String> = plan_items.iter().map(|(a, c)| format!("{}{}", &a[..1], c)).collect();
frame.render_widget(Paragraph::new(Line::from(vec![
Span::raw(" plan: "),
Span::styled(format!("{}", plan_total), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))),
])), plan_area);
}
fn render_tasks(frame: &mut Frame, tasks: &[jobkit::TaskInfo], area: Rect) {
let mut lines: Vec<Line> = Vec::new();
let section = Style::default().fg(Color::Yellow);
let dim = Style::default().fg(Color::DarkGray);
let running: Vec<_> = tasks.iter().filter(|t| matches!(t.status, jobkit::TaskStatus::Running)).collect();
let completed: Vec<_> = tasks.iter().filter(|t| matches!(t.status, jobkit::TaskStatus::Completed)).collect();
let failed: Vec<_> = tasks.iter().filter(|t| matches!(t.status, jobkit::TaskStatus::Failed)).collect();
lines.push(Line::styled("── Tasks ──", section));
lines.push(Line::raw(format!(" Running: {} Completed: {} Failed: {}", running.len(), completed.len(), failed.len())));
lines.push(Line::raw(""));
if !running.is_empty() {
for task in &running {
let elapsed = task.started_at.map(|s| {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64();
format!("{}s", (now - s) as u64)
}).unwrap_or_default();
lines.push(Line::from(vec![
Span::raw(" "), Span::styled("", Style::default().fg(Color::Green)),
Span::raw(format!(" {} ({})", task.name, elapsed)),
]));
}
lines.push(Line::raw(""));
}
if !completed.is_empty() {
lines.push(Line::styled(" Recent:", dim));
for task in completed.iter().rev().take(10) {
lines.push(Line::from(vec![
Span::raw(" "), Span::styled("", Style::default().fg(Color::Green)),
Span::raw(format!(" {}", task.name)),
]));
}
}
if !failed.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(" Failed:", Style::default().fg(Color::Red)));
for task in failed.iter().rev().take(5) {
lines.push(Line::from(vec![
Span::raw(" "), Span::styled("", Style::default().fg(Color::Red)),
Span::raw(format!(" {}", task.name)),
]));
}
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}