mind: double-buffer MindState for UI diffing

UI event loop clones MindState on each render tick, diffs against
the previous copy, and generates status updates from changes. Mind
no longer sends UiMessage::StatusUpdate — state changes are detected
automatically by the UI.

Removes update_status from both Mind and event_loop. DMN state
changes, turn tracking, scoring status all flow through the diff.

Zero UiMessage sends from Mind's run loop for state changes.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 03:24:08 -04:00
parent 54cd3783eb
commit 07ca136c14
3 changed files with 24 additions and 33 deletions

View file

@ -37,6 +37,7 @@ fn compaction_threshold(app: &AppConfig) -> u32 {
/// Shared state between Mind and UI.
#[derive(Clone)]
pub struct MindState {
/// Pending user input — UI pushes, Mind consumes after turn completes.
pub input: Vec<String>,
@ -319,10 +320,7 @@ impl Mind {
Some(msg) = input_rx.recv() => {
match msg {
MindMessage::Hotkey(HotkeyAction::CycleAutonomy) => {
let label = self.shared.lock().unwrap().cycle_autonomy();
let _ = self.ui_tx.send(UiMessage::Info(
format!("DMN → {} (Ctrl+P to cycle)", label),
));
self.shared.lock().unwrap().cycle_autonomy();
}
MindMessage::Hotkey(HotkeyAction::Interrupt) => {
self.shared.lock().unwrap().interrupt();
@ -333,7 +331,6 @@ impl Mind {
if let Some(h) = self.turn_handle.take() { h.abort(); }
self.shared.lock().unwrap().turn_active = false;
let _ = self.turn_watch.send(false);
let _ = self.ui_tx.send(UiMessage::Info("(interrupted)".into()));
}
MindMessage::NewSession => {
self.shared.lock().unwrap().dmn_sleep();
@ -349,7 +346,6 @@ impl Mind {
self.config.app.clone(), self.config.prompt_file.clone(),
new_log, shared_ctx, shared_tools,
);
let _ = self.ui_tx.send(UiMessage::Info("New session started.".into()));
}
MindMessage::Score => {
let mut s = self.shared.lock().unwrap();
@ -357,16 +353,12 @@ impl Mind {
s.scoring_in_flight = true;
drop(s);
self.start_memory_scoring();
} else {
let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into()));
}
}
_ => {}
}
// Check for pending input
let action = self.shared.lock().unwrap().take_pending_input();
self.execute(action);
crate::user::event_loop::update_status(&self.shared, &self.ui_tx);
}
Some((result, target)) = turn_rx.recv() => {
@ -374,20 +366,12 @@ impl Mind {
let model_switch = self.shared.lock().unwrap().complete_turn(&result, target);
let _ = self.turn_watch.send(false);
if let Err(ref e) = result {
let msg = match target {
StreamTarget::Autonomous => UiMessage::DmnAnnotation(format!("[error: {:#}]", e)),
StreamTarget::Conversation => UiMessage::Info(format!("Error: {:#}", e)),
};
let _ = self.ui_tx.send(msg);
}
if let Some(name) = model_switch {
crate::user::event_loop::cmd_switch_model(&self.agent, &name, &self.ui_tx).await;
}
self.check_compaction();
if !self.config.no_agents { self.start_memory_scoring(); }
crate::user::event_loop::update_status(&self.shared, &self.ui_tx);
let action = self.shared.lock().unwrap().take_pending_input();
self.execute(action);
@ -400,7 +384,6 @@ impl Mind {
let action = self.shared.lock().unwrap().dmn_tick();
self.execute(action);
}
crate::user::event_loop::update_status(&self.shared, &self.ui_tx);
}
}
}
@ -500,7 +483,6 @@ pub async fn run(cli: crate::user::CliArgs) -> Result<()> {
let shared_mind = shared_mind_state(config.app.dmn.max_turns);
crate::user::event_loop::send_context_info(&config, &ui_tx);
let mut mind = Mind::new(agent, shared_mind.clone(), config, ui_tx.clone(), turn_tx);
crate::user::event_loop::update_status(&shared_mind, &ui_tx);
if !no_agents {
mind.start_memory_scoring();
}