rendering

This commit is contained in:
Kent Overstreet 2026-04-05 23:04:10 -04:00
parent 36d698a3e1
commit 49cd6d6ab6
8 changed files with 178 additions and 157 deletions

View file

@ -10,8 +10,8 @@ pub mod unconscious;
pub mod thalamus;
use anyhow::Result;
use ratatui::crossterm::event::{Event, EventStream, KeyEventKind};
use futures::StreamExt;
use ratatui::crossterm::event::{Event, KeyEventKind};
use std::io::Write;
use std::time::Duration;
use crate::mind::MindCommand;
@ -88,7 +88,7 @@ pub enum ScreenAction {
/// A screen that can draw itself and handle input.
pub(crate) trait ScreenView: Send {
fn tick(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect,
key: Option<ratatui::crossterm::event::KeyEvent>, app: &mut App) -> Option<ScreenAction>;
events: &[ratatui::crossterm::event::Event], app: &mut App);
fn label(&self) -> &'static str;
}
@ -128,7 +128,6 @@ pub struct App {
pub(crate) active_tools: crate::agent::tools::SharedActiveTools,
pub should_quit: bool,
pub submitted: Vec<String>,
pub hotkey_actions: Vec<HotkeyAction>,
pub(crate) context_info: Option<ContextInfo>,
pub(crate) shared_context: SharedContextState,
pub(crate) agent_state: Vec<crate::subconscious::subconscious::AgentSnapshot>,
@ -151,28 +150,13 @@ impl App {
top_p: 0.95,
top_k: 20,
active_tools,
should_quit: false, submitted: Vec::new(), hotkey_actions: Vec::new(),
should_quit: false, submitted: Vec::new(),
context_info: None, shared_context,
agent_state: Vec::new(),
channel_status: Vec::new(), idle_info: None,
}
}
pub fn handle_global_key_old(&mut self, _key: KeyEvent) -> bool { false } // placeholder
/// Handle global keys only (Ctrl combos).
pub fn handle_global_key(&mut self, key: KeyEvent) -> bool {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => { self.should_quit = true; return true; }
KeyCode::Char('r') => { self.hotkey_actions.push(HotkeyAction::CycleReasoning); return true; }
KeyCode::Char('k') => { self.hotkey_actions.push(HotkeyAction::KillProcess); return true; }
KeyCode::Char('p') => { self.hotkey_actions.push(HotkeyAction::CycleAutonomy); return true; }
_ => {}
}
}
false
}
pub fn set_channel_status(&mut self, channels: Vec<(String, bool, u32)>) {
self.channel_status = channels.into_iter()
@ -314,6 +298,20 @@ fn hotkey_adjust_sampling(mind: &crate::mind::Mind, param: usize, delta: f32) {
}
}
/// Returns true if this is an event the main loop handles (F-keys, Ctrl combos, resize).
fn is_global_event(event: &ratatui::crossterm::event::Event) -> bool {
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers, KeyEventKind};
match event {
Event::Key(key) => {
if key.kind != KeyEventKind::Press { return false; }
matches!(key.code, KeyCode::F(_))
|| key.modifiers.contains(KeyModifiers::CONTROL)
}
Event::Resize(_, _) => true,
_ => false,
}
}
fn diff_mind_state(
cur: &crate::mind::MindState,
@ -373,23 +371,38 @@ pub async fn run(
tui::set_screen_legend(tui::screen_legend_from(&interact, &screens));
let mut terminal = tui::init_terminal()?;
let mut reader = EventStream::new();
let mut render_interval = tokio::time::interval(Duration::from_millis(50));
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// Terminal event reader — dedicated thread reads sync, pushes to channel
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
std::thread::spawn(move || {
loop {
match crossterm::event::read() {
Ok(event) => { if event_tx.send(event).is_err() { break; } }
Err(_) => break,
}
}
});
let agent_changed = agent.lock().await.changed.clone();
let mut turn_watch = mind.turn_watch();
let mut dirty = true;
let mut prev_mind = shared_mind.lock().unwrap().clone();
let mut pending_key: Option<ratatui::crossterm::event::KeyEvent> = None;
let mut pending: Vec<ratatui::crossterm::event::Event> = Vec::new();
terminal.hide_cursor()?;
if let Ok(mut ag) = agent.try_lock() { ag.notify("consciousness v0.3"); }
// Initial render
terminal.draw(|f| {
let area = f.area();
interact.tick(f, area, None, &mut app);
})?;
{
let mut frame = terminal.get_frame();
let area = frame.area();
interact.tick(&mut frame, area, &[], &mut app);
drop(frame);
terminal.flush()?;
terminal.swap_buffers();
terminal.backend_mut().flush()?;
}
// Replay conversation after Mind init completes (non-blocking check)
let mut startup_done = false;
@ -398,122 +411,114 @@ pub async fn run(
tokio::select! {
biased;
maybe_event = reader.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) => {
if key.kind != KeyEventKind::Press { continue; }
idle_state.user_activity();
// F-keys switch screens
if let ratatui::crossterm::event::KeyCode::F(n) = key.code {
active_screen = match n {
1 => 0, // interact
n @ 2..=5 if (n as usize - 2) < screens.len() => n as usize - 1,
_ => active_screen,
};
if active_screen == 4 { // thalamus — refresh channels
let tx = channel_tx.clone();
tokio::spawn(async move {
let result = crate::thalamus::channels::fetch_all_channels().await;
let _ = tx.send(result).await;
});
}
dirty = true;
continue;
}
// Global keys (Ctrl combos) — only pass to screen if not consumed
if !app.handle_global_key(key) {
pending_key = Some(key);
}
dirty = true;
}
Some(Ok(Event::Mouse(_mouse))) => {
// TODO: route to active screen
dirty = true;
}
Some(Ok(Event::Resize(_w, _h))) => {
terminal.clear()?;
dirty = true;
}
Some(Err(_)) => break,
None => break,
_ => continue,
Some(event) = event_rx.recv() => {
pending.push(event);
while let Ok(event) = event_rx.try_recv() {
pending.push(event);
}
}
_ = agent_changed.notified() => {
dirty = true;
}
_ = render_interval.tick() => {
idle_state.decay_ewma();
app.update_idle(&idle_state);
// One-time: mark startup done after Mind init
if !startup_done {
if let Ok(mut ag) = agent.try_lock() {
let model = ag.model().to_string();
ag.notify(format!("model: {}", model));
startup_done = true;
dirty = true;
}
}
// Diff MindState — generate UI messages from changes
{
let cur = shared_mind.lock().unwrap();
diff_mind_state(&cur, &prev_mind, &mut dirty);
prev_mind = cur.clone();
}
while let Ok(notif) = notify_rx.try_recv() {
let tx = channel_tx.clone();
tokio::spawn(async move {
let result = crate::thalamus::channels::fetch_all_channels().await;
let _ = tx.send(result).await;
});
}
_ = turn_watch.changed() => {
dirty = true;
}
Some(channels) = channel_rx.recv() => {
app.set_channel_status(channels);
dirty = true;
}
}
// Handle hotkey actions
let actions: Vec<HotkeyAction> = app.hotkey_actions.drain(..).collect();
for action in actions {
match action {
HotkeyAction::CycleReasoning => hotkey_cycle_reasoning(mind),
HotkeyAction::KillProcess => hotkey_kill_processes(mind).await,
HotkeyAction::Interrupt => { let _ = mind_tx.send(MindCommand::Interrupt); }
HotkeyAction::CycleAutonomy => hotkey_cycle_autonomy(mind),
HotkeyAction::AdjustSampling(param, delta) => hotkey_adjust_sampling(mind, param, delta),
// State sync on every wake
idle_state.decay_ewma();
app.update_idle(&idle_state);
if !startup_done {
if let Ok(mut ag) = agent.try_lock() {
let model = ag.model().to_string();
ag.notify(format!("model: {}", model));
startup_done = true;
dirty = true;
}
}
{
let cur = shared_mind.lock().unwrap();
diff_mind_state(&cur, &prev_mind, &mut dirty);
prev_mind = cur.clone();
}
while let Ok(_notif) = notify_rx.try_recv() {
let tx = channel_tx.clone();
tokio::spawn(async move {
let result = crate::thalamus::channels::fetch_all_channels().await;
let _ = tx.send(result).await;
});
}
if !pending.is_empty() { idle_state.user_activity(); }
while !pending.is_empty() {
let global_pos = pending.iter().position(|e| is_global_event(e))
.unwrap_or(pending.len());
if global_pos > 0 {
let mut frame = terminal.get_frame();
let area = frame.area();
if active_screen == 0 {
interact.tick(&mut frame, area, &pending[..global_pos], &mut app);
} else {
screens[active_screen - 1].tick(&mut frame, area, &pending[..global_pos], &mut app);
}
drop(frame);
terminal.flush()?;
terminal.swap_buffers();
terminal.backend_mut().flush()?;
dirty = false;
}
pending = pending.split_off(global_pos);
if pending.is_empty() { break; }
// Global event is first — handle it
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
let event = pending.remove(0);
match event {
Event::Key(key) => {
if let KeyCode::F(n) = key.code {
active_screen = match n {
1 => 0,
n @ 2..=5 if (n as usize - 2) < screens.len() => n as usize - 1,
_ => active_screen,
};
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => { app.should_quit = true; }
KeyCode::Char('r') => hotkey_cycle_reasoning(mind),
KeyCode::Char('k') => hotkey_kill_processes(mind).await,
KeyCode::Char('p') => hotkey_cycle_autonomy(mind),
_ => {}
}
}
}
Event::Resize(_, _) => { let _ = terminal.clear(); }
_ => {}
}
dirty = true;
}
if dirty {
let key = pending_key.take();
let mut screen_action = None;
let mut frame = terminal.get_frame();
let area = frame.area();
if active_screen == 0 {
terminal.draw(|f| {
let area = f.area();
screen_action = interact.tick(f, area, key, &mut app);
})?;
interact.tick(&mut frame, area, &[], &mut app);
} else {
let screen = &mut screens[active_screen - 1];
terminal.draw(|f| {
let area = f.area();
screen_action = screen.tick(f, area, key, &mut app);
})?;
}
if let Some(action) = screen_action {
match action {
tui::ScreenAction::Switch(i) => { active_screen = i; dirty = true; continue; }
tui::ScreenAction::Hotkey(h) => app.hotkey_actions.push(h),
}
screens[active_screen - 1].tick(&mut frame, area, &[], &mut app);
}
drop(frame);
terminal.flush()?;
terminal.swap_buffers();
terminal.backend_mut().flush()?;
dirty = false;
}