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

@ -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<tokio::sync::Notify>,
}
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 {

View file

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

View file

@ -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<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
// 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
}
}

View file

@ -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,9 +117,10 @@ impl ScreenView for ConsciousScreen {
fn label(&self) -> &'static str { "conscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
// Handle keys
if let Some(key) = key {
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);
@ -145,10 +146,10 @@ 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
let mut lines: Vec<Line> = Vec::new();
@ -214,6 +215,5 @@ impl ScreenView for ConsciousScreen {
.scroll((self.scroll, 0));
frame.render_widget(para, area);
None
}
}

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,56 +411,30 @@ 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() => {
_ = turn_watch.changed() => {
dirty = true;
}
Some(channels) = channel_rx.recv() => {
app.set_channel_status(channels);
dirty = true;
}
}
// State sync on every wake
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();
@ -456,64 +443,82 @@ pub async fn run(
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() {
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;
}
Some(channels) = channel_rx.recv() => {
app.set_channel_status(channels);
dirty = true;
}
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),
_ => {}
}
// 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),
}
}
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;
}

View file

@ -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<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
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,12 +46,12 @@ 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
if self.log_view {
@ -57,7 +59,6 @@ impl ScreenView for SubconsciousScreen {
} else {
self.draw_list(frame, area, app);
}
None
}
}

View file

@ -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<KeyEvent>, app: &mut App) -> Option<ScreenAction> {
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,18 +38,16 @@ 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
let section = Style::default().fg(Color::Yellow);
@ -131,6 +131,5 @@ impl ScreenView for ThalamusScreen {
.scroll((self.scroll, 0));
frame.render_widget(para, area);
None
}
}

View file

@ -38,15 +38,17 @@ impl ScreenView for UnconsciousScreen {
fn label(&self) -> &'static str { "unconscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
key: Option<KeyEvent>, _app: &mut App) -> Option<ScreenAction> {
if let Some(key) = key {
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; }
KeyCode::Esc => return Some(ScreenAction::Switch(0)),
_ => {}
}
}
}
let block = Block::default()
.title_top(Line::from(screen_legend()).left_aligned())
@ -79,7 +81,6 @@ impl ScreenView for UnconsciousScreen {
render_tasks(frame, &st.tasks, tasks_area);
}
}
None
}
}