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.
This commit is contained in:
2025-10-16 20:39:53 +02:00
parent 282dcdce88
commit ea04a25ed6
4 changed files with 199 additions and 0 deletions

View File

@@ -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<String>,
request: GenerateRequest,
) -> Result<Uuid> {
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)
}
}

View File

@@ -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<Uuid>,
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,
},
}

View File

@@ -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<ProviderManager>,
message_tx: mpsc::UnboundedSender<AppMessage>,
#[allow(dead_code)]
message_rx: mpsc::UnboundedReceiver<AppMessage>,
active_generation: Option<ActiveGeneration>,
}
impl App {
/// Construct a new application instance with an associated message channel.
pub fn new(provider_manager: Arc<ProviderManager>) -> 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<AppMessage> {
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();
}
}

View File

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