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
//
// thought/ — shared cognitive substrate (tools, context, memory ops)

View file

@ -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;

View file

@ -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<crate::user::ui_channel::ContextSection> {
self.shared_context.read().map_or_else(|_| Vec::new(), |s| s.clone())
pub(crate) struct ConsciousScreen {
scroll: u16,
selected: Option<usize>,
expanded: std::collections::HashSet<usize>,
}
/// Count total selectable items in the context state tree.
pub(crate) fn debug_item_count(&self, context_state: &[crate::user::ui_channel::ContextSection]) -> usize {
impl ConsciousScreen {
pub fn new() -> Self {
Self { scroll: 0, selected: None, expanded: std::collections::HashSet::new() }
}
fn read_context_state(&self, app: &App) -> Vec<crate::user::ui_channel::ContextSection> {
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 {
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<Line>,
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 &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 {
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<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 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
}
}

View file

@ -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;

View file

@ -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<Line<'static>> {
.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<ratatui::crossterm::event::KeyEvent>, app: &App) -> Option<ScreenAction>;
fn label(&self) -> &'static str;
}
#[derive(Debug)]
@ -285,19 +299,11 @@ pub struct App {
pub submitted: Vec<String>,
pub hotkey_actions: Vec<HotkeyAction>,
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) 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) channel_status: Vec<ChannelStatus>,
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 {
@ -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<Terminal<CrosstermBackend<io::Stdout>>> {

View file

@ -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");
if self.agent_log_view {
self.draw_agent_log(frame, size, &output_dir);
return;
pub(crate) struct SubconsciousScreen {
selected: usize,
log_view: bool,
scroll: u16,
}
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<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 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![
(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![
],
(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![
],
_ => 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<Line> = 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);
}
}

View file

@ -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<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 dim = Style::default().fg(Color::DarkGray);
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::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
}
}

View file

@ -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,127 +26,114 @@ fn fetch_status() -> Option<DaemonStatus> {
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<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()
.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);
}
}
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);
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
" {} 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::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);
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()
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,
);
.label(format!("{:.2}", gh.alpha)), g1);
let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red };
frame.render_widget(
Gauge::default()
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,
);
.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()
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,
);
.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();
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![
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::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);
])), plan_area);
}
fn render_tasks(frame: &mut Frame, tasks: &[jobkit::TaskInfo], area: Rect) {
@ -157,72 +141,48 @@ impl App {
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();
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(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();
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();
}).unwrap_or_default();
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Green)),
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(" "), 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(" "), Span::styled("", Style::default().fg(Color::Red)),
Span::raw(format!(" {}", task.name)),
]));
}
}
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }),
area,
);
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}