From ea04a25ed638e1a9d1ada7883f7816bdeb0f0f7c Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 16 Oct 2025 20:39:53 +0200 Subject: [PATCH] feat(app): add generation orchestration, messaging, and core App struct Introduce `App` with provider manager, unbounded message channel, and active generation tracking. Add `AppMessage` enum covering UI events, generation lifecycle (start, chunk, complete, error), model refresh, and provider status updates. Implement `start_generation` to spawn asynchronous generation tasks, stream results, handle errors, and abort any previous generation. Expose the new module via `pub mod app` in the crate root. --- crates/owlen-tui/src/app/generation.rs | 77 +++++++++++++++++++++++++ crates/owlen-tui/src/app/messages.rs | 41 +++++++++++++ crates/owlen-tui/src/app/mod.rs | 80 ++++++++++++++++++++++++++ crates/owlen-tui/src/lib.rs | 1 + 4 files changed, 199 insertions(+) create mode 100644 crates/owlen-tui/src/app/generation.rs create mode 100644 crates/owlen-tui/src/app/messages.rs create mode 100644 crates/owlen-tui/src/app/mod.rs diff --git a/crates/owlen-tui/src/app/generation.rs b/crates/owlen-tui/src/app/generation.rs new file mode 100644 index 0000000..6c3fb38 --- /dev/null +++ b/crates/owlen-tui/src/app/generation.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use futures_util::StreamExt; +use owlen_core::provider::GenerateRequest; +use uuid::Uuid; + +use super::{ActiveGeneration, App, AppMessage}; + +impl App { + /// Kick off a new generation task on the supplied provider. + pub fn start_generation( + &mut self, + provider_id: impl Into, + request: GenerateRequest, + ) -> Result { + let provider_id = provider_id.into(); + let request_id = Uuid::new_v4(); + + // Cancel any existing task so we don't interleave output. + if let Some(active) = self.active_generation.take() { + active.abort(); + } + + self.message_tx + .send(AppMessage::GenerateStart { + request_id, + provider_id: provider_id.clone(), + request: request.clone(), + }) + .map_err(|err| anyhow!("failed to queue generation start: {err:?}"))?; + + let manager = Arc::clone(&self.provider_manager); + let message_tx = self.message_tx.clone(); + let provider_for_task = provider_id.clone(); + + let join_handle = tokio::spawn(async move { + let mut stream = match manager.generate(&provider_for_task, request).await { + Ok(stream) => stream, + Err(err) => { + let _ = message_tx.send(AppMessage::GenerateError { + request_id: Some(request_id), + message: err.to_string(), + }); + return; + } + }; + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + if message_tx + .send(AppMessage::GenerateChunk { request_id, chunk }) + .is_err() + { + break; + } + } + Err(err) => { + let _ = message_tx.send(AppMessage::GenerateError { + request_id: Some(request_id), + message: err.to_string(), + }); + return; + } + } + } + + let _ = message_tx.send(AppMessage::GenerateComplete { request_id }); + }); + + let generation = ActiveGeneration::new(request_id, provider_id, join_handle); + self.active_generation = Some(generation); + + Ok(request_id) + } +} diff --git a/crates/owlen-tui/src/app/messages.rs b/crates/owlen-tui/src/app/messages.rs new file mode 100644 index 0000000..2a07454 --- /dev/null +++ b/crates/owlen-tui/src/app/messages.rs @@ -0,0 +1,41 @@ +use crossterm::event::KeyEvent; +use owlen_core::provider::{GenerateChunk, GenerateRequest, ProviderStatus}; +use uuid::Uuid; + +/// Messages exchanged between the UI event loop and background workers. +#[derive(Debug)] +pub enum AppMessage { + /// User input event bubbled up from the terminal layer. + KeyPress(KeyEvent), + /// Terminal resize notification. + Resize { width: u16, height: u16 }, + /// Periodic tick used to drive animations. + Tick, + /// Initiate a new text generation request. + GenerateStart { + request_id: Uuid, + provider_id: String, + request: GenerateRequest, + }, + /// Streamed response chunk from the active generation task. + GenerateChunk { + request_id: Uuid, + chunk: GenerateChunk, + }, + /// Generation finished successfully. + GenerateComplete { request_id: Uuid }, + /// Generation failed or was aborted. + GenerateError { + request_id: Option, + message: String, + }, + /// Trigger a background refresh of available models. + ModelsRefresh, + /// New model list data is ready. + ModelsUpdated, + /// Provider health status update. + ProviderStatus { + provider_id: String, + status: ProviderStatus, + }, +} diff --git a/crates/owlen-tui/src/app/mod.rs b/crates/owlen-tui/src/app/mod.rs new file mode 100644 index 0000000..b44b8dc --- /dev/null +++ b/crates/owlen-tui/src/app/mod.rs @@ -0,0 +1,80 @@ +mod generation; + +pub mod messages; + +use std::sync::Arc; + +use owlen_core::provider::ProviderManager; +use tokio::{ + sync::mpsc, + task::{AbortHandle, JoinHandle}, +}; +use uuid::Uuid; + +pub use messages::AppMessage; + +/// 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, + active_generation: Option, +} + +impl App { + /// Construct a new application instance with an associated message channel. + pub fn new(provider_manager: Arc) -> Self { + let (message_tx, message_rx) = mpsc::unbounded_channel(); + + Self { + provider_manager, + message_tx, + message_rx, + active_generation: None, + } + } + + /// Cloneable sender handle for pushing messages into the application loop. + pub fn message_sender(&self) -> mpsc::UnboundedSender { + self.message_tx.clone() + } + + /// Whether a generation task is currently in flight. + pub fn has_active_generation(&self) -> bool { + self.active_generation.is_some() + } + + /// Abort any in-flight generation task. + pub fn abort_active_generation(&mut self) { + if let Some(active) = self.active_generation.take() { + active.abort(); + } + } +} + +struct ActiveGeneration { + #[allow(dead_code)] + request_id: Uuid, + #[allow(dead_code)] + provider_id: String, + abort_handle: AbortHandle, + #[allow(dead_code)] + join_handle: JoinHandle<()>, +} + +impl ActiveGeneration { + fn new(request_id: Uuid, provider_id: String, join_handle: JoinHandle<()>) -> Self { + let abort_handle = join_handle.abort_handle(); + Self { + request_id, + provider_id, + abort_handle, + join_handle, + } + } + + fn abort(self) { + self.abort_handle.abort(); + } +} diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs index 647bcd2..08fe107 100644 --- a/crates/owlen-tui/src/lib.rs +++ b/crates/owlen-tui/src/lib.rs @@ -14,6 +14,7 @@ //! - `events`: Event handling for user input and other asynchronous actions. //! - `ui`: The rendering logic for all TUI components. +pub mod app; pub mod chat_app; pub mod code_app; pub mod commands;