From 8525819ab4ec57ed6f9ca3fafed010e4708ee840 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 16 Oct 2025 22:21:33 +0200 Subject: [PATCH] feat(app): introduce UiRuntime trait and RuntimeApp run loop, add crossterm event conversion, refactor CLI to use RuntimeApp for unified UI handling --- crates/owlen-cli/src/main.rs | 77 +++------------- crates/owlen-tui/src/app/mod.rs | 154 +++++++++++++++++++++++++++++-- crates/owlen-tui/src/chat_app.rs | 31 ++++++- crates/owlen-tui/src/events.rs | 32 ++++--- 4 files changed, 209 insertions(+), 85 deletions(-) diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 4641e6e..3a5202a 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -16,19 +16,19 @@ use owlen_core::{ config::{Config, McpMode}, mcp::remote_client::RemoteMcpClient, mode::Mode, + provider::ProviderManager, providers::OllamaProvider, session::SessionController, storage::StorageManager, types::{ChatRequest, ChatResponse, Message, ModelInfo}, }; use owlen_tui::tui_controller::{TuiController, TuiRequest}; -use owlen_tui::{AppState, ChatApp, Event, EventHandler, SessionEvent, config, ui}; +use owlen_tui::{ChatApp, SessionEvent, app::App as RuntimeApp, config, ui}; use std::any::Any; use std::borrow::Cow; use std::io; use std::sync::Arc; use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; use crossterm::{ event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, @@ -407,6 +407,8 @@ async fn main() -> Result<()> { let controller = SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?; + let provider_manager = Arc::new(ProviderManager::default()); + let mut runtime = RuntimeApp::new(provider_manager); let (mut app, mut session_rx) = ChatApp::new(controller).await?; app.initialize_models().await?; if let Some(notice) = offline_notice { @@ -417,12 +419,6 @@ async fn main() -> Result<()> { // Set the initial mode app.set_mode(initial_mode).await; - // Event infrastructure - let cancellation_token = CancellationToken::new(); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let event_handler = EventHandler::new(event_tx, cancellation_token.clone()); - let event_handle = tokio::spawn(async move { event_handler.run().await }); - // Terminal setup enable_raw_mode()?; let mut stdout = io::stdout(); @@ -435,11 +431,7 @@ async fn main() -> Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_app(&mut terminal, &mut app, event_rx, &mut session_rx).await; - - // Shutdown - cancellation_token.cancel(); - event_handle.await?; + let result = run_app(&mut terminal, &mut runtime, &mut app, &mut session_rx).await; // Persist configuration updates (e.g., selected model) config::save_config(&app.config())?; @@ -462,58 +454,17 @@ async fn main() -> Result<()> { async fn run_app( terminal: &mut Terminal>, + runtime: &mut RuntimeApp, app: &mut ChatApp, - mut event_rx: mpsc::UnboundedReceiver, session_rx: &mut mpsc::UnboundedReceiver, ) -> Result<()> { - let stream_draw_interval = tokio::time::Duration::from_millis(50); - let idle_tick = tokio::time::Duration::from_millis(100); - let mut last_draw = tokio::time::Instant::now() - stream_draw_interval; + let mut render = |terminal: &mut Terminal>, + state: &mut ChatApp| + -> Result<()> { + terminal.draw(|f| ui::render_chat(f, state))?; + Ok(()) + }; - loop { - // Advance loading animation frame - app.advance_loading_animation(); - - let streaming_active = app.streaming_count() > 0; - let draw_due = if streaming_active { - last_draw.elapsed() >= stream_draw_interval - } else { - true - }; - - if draw_due { - terminal.draw(|f| ui::render_chat(f, app))?; - last_draw = tokio::time::Instant::now(); - } - - // Process any pending LLM requests AFTER UI has been drawn - if let Err(e) = app.process_pending_llm_request().await { - eprintln!("Error processing LLM request: {}", e); - } - - // Process any pending tool executions AFTER UI has been drawn - if let Err(e) = app.process_pending_tool_execution().await { - eprintln!("Error processing tool execution: {}", e); - } - - let sleep_duration = if streaming_active { - stream_draw_interval - .checked_sub(last_draw.elapsed()) - .unwrap_or_else(|| tokio::time::Duration::from_millis(0)) - } else { - idle_tick - }; - - tokio::select! { - Some(event) = event_rx.recv() => { - if let AppState::Quit = app.handle_event(event).await? { - return Ok(()); - } - } - Some(session_event) = session_rx.recv() => { - app.handle_session_event(session_event).await?; - } - _ = tokio::time::sleep(sleep_duration) => {} - } - } + runtime.run(terminal, app, session_rx, &mut render).await?; + Ok(()) } diff --git a/crates/owlen-tui/src/app/mod.rs b/crates/owlen-tui/src/app/mod.rs index 4bd0d50..5f968ea 100644 --- a/crates/owlen-tui/src/app/mod.rs +++ b/crates/owlen-tui/src/app/mod.rs @@ -4,24 +4,43 @@ mod worker; pub mod messages; -use std::sync::Arc; +use std::{ + io, + sync::Arc, + time::{Duration, Instant}, +}; -use owlen_core::provider::ProviderManager; +use anyhow::Result; +use async_trait::async_trait; +use crossterm::event::{self, KeyEventKind}; +use owlen_core::{provider::ProviderManager, state::AppState}; +use ratatui::{Terminal, backend::CrosstermBackend}; use tokio::{ - sync::mpsc, - task::{AbortHandle, JoinHandle}, + sync::mpsc::{self, error::TryRecvError}, + task::{AbortHandle, JoinHandle, yield_now}, }; use uuid::Uuid; +use crate::{Event, SessionEvent, events}; + pub use handler::MessageState; pub use messages::AppMessage; +#[async_trait] +pub trait UiRuntime: MessageState { + async fn handle_ui_event(&mut self, event: Event) -> Result; + async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>; + async fn process_pending_llm_request(&mut self) -> Result<()>; + async fn process_pending_tool_execution(&mut self) -> Result<()>; + fn advance_loading_animation(&mut self); + fn streaming_count(&self) -> usize; +} + /// High-level application state driving the non-blocking TUI. pub struct App { provider_manager: Arc, message_tx: mpsc::UnboundedSender, - #[allow(dead_code)] - message_rx: mpsc::UnboundedReceiver, + message_rx: Option>, active_generation: Option, } @@ -33,7 +52,7 @@ impl App { Self { provider_manager, message_tx, - message_rx, + message_rx: Some(message_rx), active_generation: None, } } @@ -64,6 +83,127 @@ impl App { worker::background_worker(manager, sender).await; }) } + + /// Drive the main UI loop, handling terminal events, background messages, and + /// provider status updates without blocking rendering. + pub async fn run( + &mut self, + terminal: &mut Terminal>, + state: &mut State, + session_rx: &mut mpsc::UnboundedReceiver, + mut render: RenderFn, + ) -> Result + where + State: UiRuntime, + RenderFn: FnMut(&mut Terminal>, &mut State) -> Result<()>, + { + let mut message_rx = self + .message_rx + .take() + .expect("App::run called without an available message receiver"); + + let poll_interval = Duration::from_millis(16); + let mut last_frame = Instant::now(); + let frame_interval = Duration::from_millis(16); + + let mut worker_handle = Some(self.spawn_background_worker()); + + let exit_state = AppState::Quit; + 'main: loop { + state.advance_loading_animation(); + + state.process_pending_llm_request().await?; + state.process_pending_tool_execution().await?; + + loop { + match session_rx.try_recv() { + Ok(session_event) => { + state.handle_session_event(session_event).await?; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + break 'main; + } + } + } + + loop { + match message_rx.try_recv() { + Ok(message) => { + if self.handle_message(state, message) { + if let Some(handle) = worker_handle.take() { + handle.abort(); + } + break 'main; + } + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + + if last_frame.elapsed() >= frame_interval { + render(terminal, state)?; + last_frame = Instant::now(); + } + + if self.handle_message(state, AppMessage::Tick) { + if let Some(handle) = worker_handle.take() { + handle.abort(); + } + 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); + + Ok(exit_state) + } } struct ActiveGeneration { diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index b8755e0..8143830 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result, anyhow}; +use async_trait::async_trait; use chrono::{DateTime, Local, Utc}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use owlen_core::mcp::remote_client::RemoteMcpClient; @@ -32,6 +33,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use uuid::Uuid; +use crate::app::{MessageState, UiRuntime}; use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; @@ -11201,4 +11203,31 @@ fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { textarea.set_cursor_line_style(Style::default()); } -impl crate::app::MessageState for ChatApp {} +impl MessageState for ChatApp {} + +#[async_trait] +impl UiRuntime for ChatApp { + async fn handle_ui_event(&mut self, event: Event) -> Result { + ChatApp::handle_event(self, event).await + } + + async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> { + ChatApp::handle_session_event(self, event).await + } + + async fn process_pending_llm_request(&mut self) -> Result<()> { + ChatApp::process_pending_llm_request(self).await + } + + async fn process_pending_tool_execution(&mut self) -> Result<()> { + ChatApp::process_pending_tool_execution(self).await + } + + fn advance_loading_animation(&mut self) { + ChatApp::advance_loading_animation(self); + } + + fn streaming_count(&self) -> usize { + ChatApp::streaming_count(self) + } +} diff --git a/crates/owlen-tui/src/events.rs b/crates/owlen-tui/src/events.rs index dd27af4..a2722dd 100644 --- a/crates/owlen-tui/src/events.rs +++ b/crates/owlen-tui/src/events.rs @@ -17,6 +17,22 @@ pub enum Event { Tick, } +/// Convert a raw crossterm event into an application event. +pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option { + match raw { + crossterm::event::Event::Key(key) => { + if key.kind == KeyEventKind::Press { + Some(Event::Key(key)) + } else { + None + } + } + crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)), + crossterm::event::Event::Paste(text) => Some(Event::Paste(text)), + _ => None, + } +} + /// Event handler that captures terminal events and sends them to the application pub struct EventHandler { sender: mpsc::UnboundedSender, @@ -52,20 +68,8 @@ impl EventHandler { if event::poll(timeout).unwrap_or(false) { match event::read() { Ok(event) => { - match event { - crossterm::event::Event::Key(key) => { - // Only handle KeyEventKind::Press to avoid duplicate events - if key.kind == KeyEventKind::Press { - let _ = self.sender.send(Event::Key(key)); - } - } - crossterm::event::Event::Resize(width, height) => { - let _ = self.sender.send(Event::Resize(width, height)); - } - crossterm::event::Event::Paste(text) => { - let _ = self.sender.send(Event::Paste(text)); - } - _ => {} + if let Some(converted) = from_crossterm_event(event) { + let _ = self.sender.send(converted); } } Err(_) => {