feat(tui): add AppEvent dispatch loop

This commit is contained in:
2025-10-26 02:05:14 +01:00
parent c92e07b866
commit 76e59c2d0e

View File

@@ -6,21 +6,19 @@ mod worker;
pub mod messages; pub mod messages;
pub use worker::background_worker; pub use worker::background_worker;
use std::{ use std::{io, sync::Arc, time::Duration};
io,
sync::Arc,
time::{Duration, Instant},
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use crossterm::event::{self, KeyEventKind}; use crossterm::event;
use owlen_core::{provider::ProviderManager, state::AppState}; use owlen_core::{provider::ProviderManager, state::AppState};
use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::{ use tokio::{
sync::mpsc::{self, error::TryRecvError}, sync::mpsc,
task::{AbortHandle, JoinHandle, yield_now}, task::{self, AbortHandle, JoinHandle},
time::{MissedTickBehavior, interval},
}; };
use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
use crate::{Event, SessionEvent, events}; use crate::{Event, SessionEvent, events};
@@ -28,6 +26,20 @@ use crate::{Event, SessionEvent, events};
pub use handler::MessageState; pub use handler::MessageState;
pub use messages::AppMessage; pub use messages::AppMessage;
#[derive(Debug)]
enum AppEvent {
Message(AppMessage),
Session(SessionEvent),
Ui(Event),
FrameTick,
}
#[derive(Debug, Clone, Copy)]
enum LoopControl {
Continue,
Exit(AppState),
}
#[async_trait] #[async_trait]
pub trait UiRuntime: MessageState { pub trait UiRuntime: MessageState {
async fn handle_ui_event(&mut self, event: Event) -> Result<AppState>; async fn handle_ui_event(&mut self, event: Event) -> Result<AppState>;
@@ -105,109 +117,154 @@ impl App {
.take() .take()
.expect("App::run called without an available message receiver"); .expect("App::run called without an available message receiver");
let poll_interval = Duration::from_millis(16); let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel::<AppEvent>();
let mut last_frame = Instant::now(); let (input_cancel, input_handle) = Self::spawn_input_listener(app_event_tx.clone());
let frame_interval = Duration::from_millis(16); drop(app_event_tx);
let mut frame_interval = interval(Duration::from_millis(16));
frame_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut worker_handle = Some(self.spawn_background_worker()); let mut worker_handle = Some(self.spawn_background_worker());
let mut exit_state = AppState::Quit;
let exit_state = AppState::Quit;
'main: loop {
state.advance_loading_animation();
state.process_pending_llm_request().await?;
state.process_pending_tool_execution().await?;
state.poll_controller_events()?;
loop { loop {
match session_rx.try_recv() { self.pump_background(state).await?;
Ok(session_event) => {
state.handle_session_event(session_event).await?;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
break 'main;
}
}
}
loop { let next_event = tokio::select! {
match message_rx.try_recv() { Some(event) = app_event_rx.recv() => event,
Ok(message) => { Some(message) = message_rx.recv() => AppEvent::Message(message),
if self.handle_message(state, message) { Some(session_event) = session_rx.recv() => AppEvent::Session(session_event),
if let Some(handle) = worker_handle.take() { _ = frame_interval.tick() => AppEvent::FrameTick,
handle.abort(); else => break,
} };
break 'main;
}
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
if last_frame.elapsed() >= frame_interval { let is_frame_tick = matches!(next_event, AppEvent::FrameTick);
match self.dispatch_app_event(state, next_event).await? {
LoopControl::Continue => {
if is_frame_tick {
render(terminal, state)?; render(terminal, state)?;
last_frame = Instant::now(); }
}
LoopControl::Exit(state_value) => {
exit_state = state_value;
break;
}
}
} }
if self.handle_message(state, AppMessage::Tick) { input_cancel.cancel();
let _ = input_handle.await;
if let Some(handle) = worker_handle.take() { if let Some(handle) = worker_handle.take() {
handle.abort(); handle.abort();
} let _ = handle.await;
break 'main;
}
match event::poll(poll_interval) {
Ok(true) => match event::read() {
Ok(raw_event) => {
if let Some(ui_event) = events::from_crossterm_event(raw_event) {
if let Event::Key(key) = &ui_event {
if key.kind == KeyEventKind::Press {
let _ = self.message_tx.send(AppMessage::KeyPress(*key));
}
} else if let Event::Resize(width, height) = &ui_event {
let _ = self.message_tx.send(AppMessage::Resize {
width: *width,
height: *height,
});
}
if matches!(state.handle_ui_event(ui_event).await?, AppState::Quit) {
if let Some(handle) = worker_handle.take() {
handle.abort();
}
break 'main;
}
}
}
Err(err) => {
if let Some(handle) = worker_handle.take() {
handle.abort();
}
return Err(err.into());
}
},
Ok(false) => {}
Err(err) => {
if let Some(handle) = worker_handle.take() {
handle.abort();
}
return Err(err.into());
}
}
yield_now().await;
}
if let Some(handle) = worker_handle {
handle.abort();
} }
self.message_rx = Some(message_rx); self.message_rx = Some(message_rx);
Ok(exit_state) Ok(exit_state)
} }
async fn pump_background<State>(&mut self, state: &mut State) -> Result<()>
where
State: UiRuntime,
{
state.advance_loading_animation();
state.process_pending_llm_request().await?;
state.process_pending_tool_execution().await?;
state.poll_controller_events()?;
Ok(())
}
async fn dispatch_app_event<State>(
&mut self,
state: &mut State,
event: AppEvent,
) -> Result<LoopControl>
where
State: UiRuntime,
{
let control = match event {
AppEvent::Message(message) => {
if self.handle_message(state, message) {
LoopControl::Exit(AppState::Quit)
} else {
LoopControl::Continue
}
}
AppEvent::Session(session_event) => {
state.handle_session_event(session_event).await?;
LoopControl::Continue
}
AppEvent::Ui(ui_event) => {
match &ui_event {
Event::Key(key) => {
let _ = self.message_tx.send(AppMessage::KeyPress(*key));
}
Event::Resize(width, height) => {
let _ = self.message_tx.send(AppMessage::Resize {
width: *width,
height: *height,
});
}
Event::Tick => {
if self.handle_message(state, AppMessage::Tick) {
return Ok(LoopControl::Exit(AppState::Quit));
}
return Ok(LoopControl::Continue);
}
_ => {}
}
let outcome = state.handle_ui_event(ui_event).await?;
if matches!(outcome, AppState::Quit) {
LoopControl::Exit(outcome)
} else {
LoopControl::Continue
}
}
AppEvent::FrameTick => {
if self.handle_message(state, AppMessage::Tick) {
LoopControl::Exit(AppState::Quit)
} else {
LoopControl::Continue
}
}
};
Ok(control)
}
fn spawn_input_listener(
sender: mpsc::UnboundedSender<AppEvent>,
) -> (CancellationToken, JoinHandle<()>) {
let cancellation = CancellationToken::new();
let handle = task::spawn_blocking({
let cancellation = cancellation.clone();
move || {
let poll_interval = Duration::from_millis(16);
while !cancellation.is_cancelled() {
match event::poll(poll_interval) {
Ok(true) => match event::read() {
Ok(raw_event) => {
if let Some(ui_event) = events::from_crossterm_event(raw_event) {
if sender.send(AppEvent::Ui(ui_event)).is_err() {
break;
}
}
}
Err(_) => continue,
},
Ok(false) => {}
Err(_) => {}
}
}
}
});
(cancellation, handle)
}
} }
struct ActiveGeneration { struct ActiveGeneration {