From 927cddd864b5be9044abf9921dcee1adfd434f16 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 5 Apr 2026 17:54:40 -0400 Subject: [PATCH] user: ScreenView trait, overlay screens extracted from App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib.rs | 2 + src/user/chat.rs | 4 +- src/user/context.rs | 146 +++++++++++------- src/user/event_loop.rs | 2 +- src/user/mod.rs | 142 +++--------------- src/user/subconscious.rs | 121 +++++++++------ src/user/thalamus.rs | 96 +++++++----- src/user/unconscious.rs | 314 +++++++++++++++++---------------------- 8 files changed, 388 insertions(+), 439 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cb1157e..6785933 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(coroutines, coroutine_trait)] + // consciousness — unified crate for memory, agents, and subconscious processes // // thought/ — shared cognitive substrate (tools, context, memory ops) diff --git a/src/user/chat.rs b/src/user/chat.rs index bc95d69..067f418 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -11,7 +11,7 @@ use ratatui::{ Frame, }; -use super::{ActivePane, App, Marker, PaneState, SCREEN_LEGEND}; +use super::{ActivePane, App, Marker, PaneState, screen_legend}; impl App { /// Draw the main (F1) screen — four-pane layout with status bar. @@ -62,7 +62,7 @@ impl App { // Draw autonomous pane let auto_active = self.active_pane == ActivePane::Autonomous; draw_pane(frame, auto_area, "autonomous", &mut self.autonomous, auto_active, - Some(SCREEN_LEGEND)); + Some(&screen_legend())); // Draw tools pane let tools_active = self.active_pane == ActivePane::Tools; diff --git a/src/user/context.rs b/src/user/context.rs index d4ac5c0..5a7a6ea 100644 --- a/src/user/context.rs +++ b/src/user/context.rs @@ -9,18 +9,27 @@ use ratatui::{ text::Line, widgets::{Block, Borders, Paragraph, Wrap}, Frame, + crossterm::event::{KeyCode, KeyEvent}, }; -use super::{App, SCREEN_LEGEND}; +use super::{App, ScreenAction, ScreenView, screen_legend}; -impl App { - /// Read the live context state from the shared lock. - pub(crate) fn read_context_state(&self) -> Vec { - self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone()) +pub(crate) struct ConsciousScreen { + scroll: u16, + selected: Option, + expanded: std::collections::HashSet, +} + +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. - pub(crate) fn debug_item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize { + fn read_context_state(&self, app: &App) -> Vec { + 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, idx: &mut usize) -> usize { let my_idx = *idx; *idx += 1; @@ -35,50 +44,39 @@ impl App { let mut idx = 0; let mut total = 0; for section in context_state { - total += count_section(section, &self.debug_expanded, &mut idx); + total += count_section(section, &self.expanded, &mut idx); } total } - /// Keep the viewport scrolled so the selected item is visible. - /// Assumes ~1 line per item plus a header offset of ~8 lines. - pub(crate) fn scroll_to_selected(&mut self, _item_count: usize) { - let header_lines = 8u16; // model info + context state header - if let Some(sel) = self.debug_selected { + 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; - // Keep cursor within a comfortable range of the viewport - if sel_line < self.debug_scroll + 2 { - self.debug_scroll = sel_line.saturating_sub(2); - } else if sel_line > self.debug_scroll + 30 { - self.debug_scroll = sel_line.saturating_sub(15); + 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); } } } - /// Render a context section as a tree node with optional children. - pub(crate) fn render_debug_section( + fn render_section( &self, section: &crate::user::ui_channel::ContextSection, depth: usize, - start_idx: usize, lines: &mut Vec, idx: &mut usize, ) { let my_idx = *idx; - let selected = self.debug_selected == Some(my_idx); - let expanded = self.debug_expanded.contains(&my_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 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) @@ -91,7 +89,7 @@ impl App { if expanded { if has_children { for child in §ion.children { - self.render_debug_section(child, depth + 1, start_idx, lines, idx); + self.render_section(child, depth + 1, lines, idx); } } else if has_content { 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. - pub(crate) fn draw_debug(&self, frame: &mut Frame, size: Rect) { +impl ScreenView for ConsciousScreen { + fn label(&self) -> &'static str { "conscious" } + + fn tick(&mut self, frame: &mut Frame, area: Rect, + key: Option, app: &App) -> Option { + // 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 = Vec::new(); - let section = Style::default().fg(Color::Yellow); + let section_style = Style::default().fg(Color::Yellow); - // Model - lines.push(Line::styled("── Model ──", section)); - let model_display = self.context_info.as_ref() - .map_or_else(|| self.status.model.clone(), |i| i.model.clone()); + lines.push(Line::styled("── Model ──", section_style)); + let model_display = app.context_info.as_ref() + .map_or_else(|| app.status.model.clone(), |i| i.model.clone()); 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!(" Prompt: {}", info.prompt_file))); lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", ")))); } lines.push(Line::raw("")); - // Context state - lines.push(Line::styled("── Context State ──", section)); - lines.push(Line::raw(format!(" Prompt tokens: {}K", self.status.prompt_tokens / 1000))); - if !self.status.context_budget.is_empty() { - lines.push(Line::raw(format!(" Budget: {}", self.status.context_budget))); + lines.push(Line::styled("── Context State ──", section_style)); + lines.push(Line::raw(format!(" Prompt tokens: {}K", app.status.prompt_tokens / 1000))); + if !app.status.context_budget.is_empty() { + lines.push(Line::raw(format!(" Budget: {}", app.status.context_budget))); } - let context_state = self.read_context_state(); + let context_state = self.read_context_state(app); if !context_state.is_empty() { let total: usize = context_state.iter().map(|s| s.tokens).sum(); lines.push(Line::raw("")); @@ -146,32 +179,30 @@ impl App { )); lines.push(Line::raw("")); - // Flatten tree into indexed entries for selection let mut flat_idx = 0usize; 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", "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!(" Context message: {:>6} chars", info.context_message_chars))); } lines.push(Line::raw("")); - // Runtime - lines.push(Line::styled("── Runtime ──", section)); + lines.push(Line::styled("── Runtime ──", section_style)); lines.push(Line::raw(format!( " 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!(" Running processes: {}", self.running_processes))); - lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.lock().unwrap().len()))); + lines.push(Line::raw(format!(" Reasoning: {}", app.reasoning_effort))); + lines.push(Line::raw(format!(" Running processes: {}", app.running_processes))); + lines.push(Line::raw(format!(" Active tools: {}", app.active_tools.lock().unwrap().len()))); 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()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -179,8 +210,9 @@ impl App { let para = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }) - .scroll((self.debug_scroll, 0)); + .scroll((self.scroll, 0)); - frame.render_widget(para, size); + frame.render_widget(para, area); + None } } diff --git a/src/user/event_loop.rs b/src/user/event_loop.rs index 3986676..c6df439 100644 --- a/src/user/event_loop.rs +++ b/src/user/event_loop.rs @@ -409,7 +409,7 @@ pub async fn run( if key.kind != KeyEventKind::Press { continue; } app.handle_key(key); idle_state.user_activity(); - if app.screen == tui::Screen::Thalamus { + if false { // TODO: check active screen is thalamus let tx = channel_tx.clone(); tokio::spawn(async move { let result = crate::thalamus::channels::fetch_all_channels().await; diff --git a/src/user/mod.rs b/src/user/mod.rs index d0f6cf0..0304d5e 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -30,7 +30,11 @@ use std::io; 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 { let mut out = String::with_capacity(text.len()); @@ -231,9 +235,19 @@ pub(crate) fn parse_markdown(md: &str) -> Vec> { .collect() } -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Screen { - Interact, Conscious, Subconscious, Unconscious, Thalamus, +/// Action returned from a screen's tick method. +pub enum ScreenAction { + /// 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, app: &App) -> Option; + fn label(&self) -> &'static str; } #[derive(Debug)] @@ -285,19 +299,11 @@ pub struct App { pub submitted: Vec, pub hotkey_actions: Vec, pub(crate) pane_areas: [Rect; 3], - pub screen: Screen, - pub(crate) debug_scroll: u16, - pub(crate) debug_selected: Option, - pub(crate) debug_expanded: std::collections::HashSet, pub(crate) context_info: Option, pub(crate) shared_context: SharedContextState, - pub(crate) agent_selected: usize, - pub(crate) agent_log_view: bool, pub(crate) agent_state: Vec, pub(crate) channel_status: Vec, pub(crate) idle_info: Option, - /// Thalamus screen: selected sampling param (0=temp, 1=top_p, 2=top_k). - pub(crate) sampling_selected: usize, } impl App { @@ -323,12 +329,9 @@ impl App { input_history: Vec::new(), history_index: None, should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(), 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, - agent_selected: 0, agent_log_view: false, agent_state: Vec::new(), - channel_status: Vec::new(), idle_info: None, sampling_selected: 0, + agent_state: Vec::new(), + 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) { if key.modifiers.contains(KeyModifiers::CONTROL) { 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 { KeyCode::Esc => { self.hotkey_actions.push(HotkeyAction::Interrupt); } 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) { 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); } @@ -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>> { diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index a7c8578..7e53d59 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -1,7 +1,4 @@ // 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::{ layout::Rect, @@ -9,19 +6,63 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, + crossterm::event::{KeyCode, KeyEvent}, }; -use super::{App, SCREEN_LEGEND}; +use super::{App, ScreenAction, ScreenView, screen_legend}; -impl App { - pub(crate) fn draw_agents(&self, frame: &mut Frame, size: Rect) { - let output_dir = crate::store::memory_dir().join("agent-output"); +pub(crate) struct SubconsciousScreen { + selected: usize, + log_view: bool, + scroll: u16, +} - if self.agent_log_view { - self.draw_agent_log(frame, size, &output_dir); - return; +impl SubconsciousScreen { + pub fn new() -> Self { + 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, app: &App) -> Option { + // 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 = Vec::new(); let section = Style::default().fg(Color::Yellow); 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::raw("")); - for (i, agent) in self.agent_state.iter().enumerate() { - let selected = i == self.agent_selected; + for (i, agent) in app.agent_state.iter().enumerate() { + let selected = i == self.selected; let prefix = if selected { "▸ " } else { " " }; let bg = if selected { Style::default().bg(Color::DarkGray) } else { Style::default() }; let status = match (&agent.pid, &agent.phase) { - (Some(pid), Some(phase)) => { - vec![ - Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Green)), - Span::styled("● ", bg.fg(Color::Green)), - Span::styled(format!("pid {} {}", pid, phase), bg), - ] - } - (None, Some(phase)) => { - // No pid but has phase — async task (e.g. memory-scoring) - vec![ - Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Cyan)), - Span::styled("◆ ", bg.fg(Color::Cyan)), - Span::styled(phase.clone(), bg), - ] - } - _ => { - vec![ - Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Gray)), - Span::styled("○ idle", bg.fg(Color::DarkGray)), - ] - } + (Some(pid), Some(phase)) => vec![ + Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Green)), + Span::styled("● ", bg.fg(Color::Green)), + Span::styled(format!("pid {} {}", pid, phase), bg), + ], + (None, Some(phase)) => vec![ + Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Cyan)), + Span::styled("◆ ", bg.fg(Color::Cyan)), + Span::styled(phase.clone(), bg), + ], + _ => vec![ + Span::styled(format!("{}{:<20}", prefix, agent.name), bg.fg(Color::Gray)), + Span::styled("○ idle", bg.fg(Color::DarkGray)), + ], }; lines.push(Line::from(status)); } 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()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); - let para = Paragraph::new(lines) - .block(block) - .scroll((self.debug_scroll, 0)); - frame.render_widget(para, size); + let para = Paragraph::new(lines).block(block).scroll((self.scroll, 0)); + frame.render_widget(para, area); } - fn draw_agent_log(&self, frame: &mut Frame, size: Rect, _output_dir: &std::path::Path) { - let agent = self.agent_state.get(self.agent_selected); + fn draw_log(&self, frame: &mut Frame, area: Rect, app: &App) { + let agent = app.agent_state.get(self.selected); let name = agent.map(|a| a.name.as_str()).unwrap_or("?"); let mut lines: Vec = Vec::new(); 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::raw("")); - // Show pid status from state match agent.and_then(|a| a.pid) { Some(pid) => { 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()) { lines.push(Line::raw(format!(" Log: {}", log_path.display()))); } lines.push(Line::raw("")); - // Show agent log tail lines.push(Line::styled("── Agent Log ──", section)); if let Some(content) = agent .and_then(|a| a.log_path.as_ref()) @@ -123,7 +152,7 @@ impl App { } 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()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -131,7 +160,7 @@ impl App { let para = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }) - .scroll((self.debug_scroll, 0)); - frame.render_widget(para, size); + .scroll((self.scroll, 0)); + frame.render_widget(para, area); } } diff --git a/src/user/thalamus.rs b/src/user/thalamus.rs index 873faa6..82fb71e 100644 --- a/src/user/thalamus.rs +++ b/src/user/thalamus.rs @@ -1,7 +1,4 @@ // 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::{ layout::Rect, @@ -9,34 +6,70 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, + crossterm::event::{KeyCode, KeyEvent}, }; -use super::{App, SCREEN_LEGEND}; +use super::{App, HotkeyAction, ScreenAction, ScreenView, screen_legend}; -impl App { - pub(crate) fn draw_thalamus(&self, frame: &mut Frame, size: Rect) { +pub(crate) struct ThalamusScreen { + 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, app: &App) -> Option { + // 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 dim = Style::default().fg(Color::DarkGray); let mut lines: Vec = Vec::new(); - // Presence / idle state from in-process thalamus + // Presence / idle state lines.push(Line::styled("── Presence ──", section)); 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 { Span::styled("present", Style::default().fg(Color::Green)) } else { Span::styled("away", Style::default().fg(Color::DarkGray)) }; lines.push(Line::from(vec![ - Span::raw(" User: "), - presence, + Span::raw(" User: "), presence, 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!(" Idle state: {}", idle.block_reason))); - if idle.dreaming { lines.push(Line::styled(" ◆ dreaming", Style::default().fg(Color::Magenta))); } @@ -48,13 +81,13 @@ impl App { } lines.push(Line::raw("")); - // Sampling parameters (↑/↓ select, ←/→ adjust) + // Sampling parameters lines.push(Line::styled("── Sampling (←/→ adjust) ──", section)); lines.push(Line::raw("")); let params = [ - format!("temperature: {:.2}", self.temperature), - format!("top_p: {:.2}", self.top_p), - format!("top_k: {}", self.top_k), + format!("temperature: {:.2}", app.temperature), + format!("top_p: {:.2}", app.top_p), + format!("top_k: {}", app.top_k), ]; for (i, label) in params.iter().enumerate() { let prefix = if i == self.sampling_selected { "▸ " } else { " " }; @@ -67,41 +100,27 @@ impl App { } lines.push(Line::raw("")); - // Channel status from cached data + // Channel status lines.push(Line::styled("── Channels ──", section)); lines.push(Line::raw("")); - - if self.channel_status.is_empty() { + if app.channel_status.is_empty() { lines.push(Line::styled(" no channels configured", dim)); } else { - for ch in &self.channel_status { - let (symbol, color) = if ch.connected { - ("●", Color::Green) - } else { - ("○", Color::Red) - }; - - let unread_str = if ch.unread > 0 { - format!(" ({} unread)", ch.unread) - } else { - String::new() - }; - + for ch in &app.channel_status { + let (symbol, color) = if ch.connected { ("●", Color::Green) } else { ("○", Color::Red) }; + let unread_str = if ch.unread > 0 { format!(" ({} unread)", ch.unread) } else { String::new() }; lines.push(Line::from(vec![ Span::raw(" "), Span::styled(symbol, Style::default().fg(color)), Span::raw(format!(" {:<24}", ch.name)), - Span::styled( - if ch.connected { "connected" } else { "disconnected" }, - Style::default().fg(color), - ), + Span::styled(if ch.connected { "connected" } else { "disconnected" }, Style::default().fg(color)), Span::styled(unread_str, Style::default().fg(Color::Yellow)), ])); } } 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()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -109,8 +128,9 @@ impl App { let para = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }) - .scroll((self.debug_scroll, 0)); + .scroll((self.scroll, 0)); - frame.render_widget(para, size); + frame.render_widget(para, area); + None } } diff --git a/src/user/unconscious.rs b/src/user/unconscious.rs index b279809..8e2a9e5 100644 --- a/src/user/unconscious.rs +++ b/src/user/unconscious.rs @@ -1,7 +1,4 @@ // 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::{ layout::{Constraint, Layout, Rect}, @@ -9,12 +6,12 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Gauge, Paragraph, Wrap}, Frame, + crossterm::event::{KeyCode, KeyEvent}, }; -use super::{App, SCREEN_LEGEND}; +use super::{App, ScreenAction, ScreenView, screen_legend}; use crate::subconscious::daemon::GraphHealth; -/// Status fetched from the daemon socket. #[derive(serde::Deserialize, Default)] struct DaemonStatus { #[allow(dead_code)] @@ -29,200 +26,163 @@ fn fetch_status() -> Option { serde_json::from_str(&json).ok() } -impl App { - pub(crate) fn draw_unconscious(&self, frame: &mut Frame, size: Rect) { +pub(crate) struct UnconsciousScreen { + 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, _app: &App) -> Option { + 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() - .title_top(Line::from(SCREEN_LEGEND).left_aligned()) + .title_top(Line::from(screen_legend()).left_aligned()) .title_top(Line::from(" unconscious ").right_aligned()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); - let inner = block.inner(size); - frame.render_widget(block, size); + let inner = block.inner(area); + frame.render_widget(block, area); let status = fetch_status(); match &status { None => { - let dim = Style::default().fg(Color::DarkGray); frame.render_widget( - Paragraph::new(Line::styled(" daemon not running", dim)), + Paragraph::new(Line::styled(" daemon not running", + Style::default().fg(Color::DarkGray))), inner, ); } Some(st) => { - // Split into health area and tasks area let has_health = st.graph_health.is_some(); let [health_area, tasks_area] = Layout::vertical([ Constraint::Length(if has_health { 9 } else { 0 }), Constraint::Min(1), - ]) - .areas(inner); + ]).areas(inner); if let Some(ref gh) = st.graph_health { - Self::render_health(frame, gh, health_area); + render_health(frame, gh, health_area); } - - Self::render_tasks(frame, &st.tasks, tasks_area); + render_tasks(frame, &st.tasks, tasks_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); - - // 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::() + 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 = 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 = 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, - ); + 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); + + 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::() + 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 = 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 = 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); +}