diff --git a/src/agent/mod.rs b/src/agent/mod.rs index f4ba0c0..91f7451 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -72,6 +72,7 @@ impl Agent { started: std::time::Instant::now(), expires_at: std::time::Instant::now() + std::time::Duration::from_secs(3600), }); + self.changed.notify_one(); id } @@ -85,6 +86,7 @@ impl Agent { started: std::time::Instant::now(), expires_at: std::time::Instant::now() + ACTIVITY_LINGER, }); + self.changed.notify_one(); } /// Remove expired activities. @@ -179,6 +181,8 @@ pub struct Agent { pub agent_cycles: crate::subconscious::subconscious::AgentCycleState, /// Shared active tools — Agent writes, TUI reads. pub active_tools: tools::SharedActiveTools, + /// Fires when agent state changes — UI wakes on this instead of polling. + pub changed: Arc, } fn render_journal(entries: &[context::JournalEntry]) -> String { @@ -237,6 +241,7 @@ impl Agent { generation: 0, agent_cycles, active_tools, + changed: Arc::new(tokio::sync::Notify::new()), }; agent.load_startup_journal(); @@ -292,6 +297,7 @@ impl Agent { } } self.context.entries.push(entry); + self.changed.notify_one(); } /// Append streaming text to the last entry (creating a partial @@ -301,6 +307,7 @@ impl Agent { let msg = entry.message_mut(); if msg.role == Role::Assistant { msg.append_content(text); + self.changed.notify_one(); return; } } @@ -308,6 +315,7 @@ impl Agent { self.context.entries.push(ConversationEntry::Message( Message::assistant(text), )); + self.changed.notify_one(); } pub fn budget(&self) -> ContextBudget { diff --git a/src/mind/mod.rs b/src/mind/mod.rs index 4197524..ffe1060 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -247,6 +247,7 @@ impl Mind { // Restore conversation let mut ag = self.agent.lock().await; ag.restore_from_log(); + ag.changed.notify_one(); drop(ag); } diff --git a/src/user/chat.rs b/src/user/chat.rs index e976a84..ad8c8fe 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -760,11 +760,14 @@ impl ScreenView for InteractScreen { fn label(&self) -> &'static str { "interact" } fn tick(&mut self, frame: &mut Frame, area: Rect, - key: Option, app: &mut App) -> Option { - // Handle keys - if let Some(key) = key { + events: &[ratatui::crossterm::event::Event], app: &mut App) { + use ratatui::crossterm::event::Event; + // Handle events + for event in events { + match event { + Event::Key(key) if key.kind == ratatui::crossterm::event::KeyEventKind::Press => { match key.code { - KeyCode::Esc => return Some(ScreenAction::Hotkey(HotkeyAction::Interrupt)), + KeyCode::Esc => { let _ = self.mind_tx.send(crate::mind::MindCommand::Interrupt); } KeyCode::Enter if !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SHIFT) => { let input: String = self.textarea.lines().join("\n"); if !input.is_empty() { @@ -809,7 +812,11 @@ impl ScreenView for InteractScreen { ActivePane::Conversation => ActivePane::Autonomous, }; } - _ => { self.textarea.input(key); } + _ => { self.textarea.input(*key); } + } + } + Event::Mouse(mouse) => { self.handle_mouse(*mouse); } + _ => {} } } @@ -835,7 +842,6 @@ impl ScreenView for InteractScreen { // Draw self.draw_main(frame, area, app); - None } } diff --git a/src/user/context.rs b/src/user/context.rs index 565f09a..5e9890e 100644 --- a/src/user/context.rs +++ b/src/user/context.rs @@ -12,7 +12,7 @@ use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, }; -use super::{App, ScreenAction, ScreenView, screen_legend}; +use super::{App, ScreenView, screen_legend}; use crate::agent::context::ContextSection; pub(crate) struct ConsciousScreen { @@ -117,13 +117,14 @@ impl ScreenView for ConsciousScreen { fn label(&self) -> &'static str { "conscious" } fn tick(&mut self, frame: &mut Frame, area: Rect, - key: Option, app: &mut 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); + events: &[ratatui::crossterm::event::Event], app: &mut App) { + for event in events { + if let ratatui::crossterm::event::Event::Key(key) = event { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } + let context_state = self.read_context_state(app); + let item_count = self.item_count(&context_state); - match key.code { + match key.code { KeyCode::Up => { self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1)); self.scroll_to_selected(item_count); @@ -145,9 +146,9 @@ impl ScreenView for ConsciousScreen { } KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); } KeyCode::PageDown => { self.scroll += 20; } - KeyCode::Esc => return Some(ScreenAction::Switch(0)), _ => {} } + } } // Draw @@ -214,6 +215,5 @@ impl ScreenView for ConsciousScreen { .scroll((self.scroll, 0)); frame.render_widget(para, area); - None } } diff --git a/src/user/mod.rs b/src/user/mod.rs index d5fd086..4716cdd 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -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, app: &mut App) -> Option; + 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, - pub hotkey_actions: Vec, pub(crate) context_info: Option, pub(crate) shared_context: SharedContextState, pub(crate) agent_state: Vec, @@ -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 = None; + let mut pending: Vec = 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 = 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; } diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index 1d68a28..85a0c24 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -27,9 +27,11 @@ impl ScreenView for SubconsciousScreen { fn label(&self) -> &'static str { "subconscious" } fn tick(&mut self, frame: &mut Frame, area: Rect, - key: Option, app: &mut App) -> Option { + events: &[ratatui::crossterm::event::Event], app: &mut App) { // Handle keys - if let Some(key) = key { + for event in events { + if let ratatui::crossterm::event::Event::Key(key) = event { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } match key.code { KeyCode::Up if !self.log_view => { self.selected = self.selected.saturating_sub(1); @@ -44,11 +46,11 @@ impl ScreenView for SubconsciousScreen { 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 @@ -57,7 +59,6 @@ impl ScreenView for SubconsciousScreen { } else { self.draw_list(frame, area, app); } - None } } diff --git a/src/user/thalamus.rs b/src/user/thalamus.rs index a37b46b..9d509ae 100644 --- a/src/user/thalamus.rs +++ b/src/user/thalamus.rs @@ -26,9 +26,11 @@ impl ScreenView for ThalamusScreen { fn label(&self) -> &'static str { "thalamus" } fn tick(&mut self, frame: &mut Frame, area: Rect, - key: Option, app: &mut App) -> Option { + events: &[ratatui::crossterm::event::Event], app: &mut App) { // Handle keys - if let Some(key) = key { + for event in events { + if let ratatui::crossterm::event::Event::Key(key) = event { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } 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); } @@ -36,17 +38,15 @@ impl ScreenView for ThalamusScreen { 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 @@ -131,6 +131,5 @@ impl ScreenView for ThalamusScreen { .scroll((self.scroll, 0)); frame.render_widget(para, area); - None } } diff --git a/src/user/unconscious.rs b/src/user/unconscious.rs index e262d46..3fa1bea 100644 --- a/src/user/unconscious.rs +++ b/src/user/unconscious.rs @@ -38,13 +38,15 @@ impl ScreenView for UnconsciousScreen { fn label(&self) -> &'static str { "unconscious" } fn tick(&mut self, frame: &mut Frame, area: Rect, - key: Option, _app: &mut 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)), - _ => {} + events: &[ratatui::crossterm::event::Event], _app: &mut App) { + for event in events { + if let ratatui::crossterm::event::Event::Key(key) = event { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } + match key.code { + KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(20); } + KeyCode::PageDown => { self.scroll += 20; } + _ => {} + } } } @@ -79,7 +81,6 @@ impl ScreenView for UnconsciousScreen { render_tasks(frame, &st.tasks, tasks_area); } } - None } }