feat(app): introduce MessageState trait and handler for AppMessage dispatch

- Add `MessageState` trait defining UI reaction callbacks for generation lifecycle, model updates, provider status, resize, and tick events.
- Implement `App::handle_message` to route `AppMessage` variants to the provided `MessageState` and determine exit condition.
- Add `handler.rs` module with the trait and dispatch logic; re-export `MessageState` in `app/mod.rs`.
- Extend `ActiveGeneration` with a public `request_id` getter and clean up dead code annotations.
- Implement empty `MessageState` for `ChatApp` to integrate UI handling.
- Add `log` crate dependency for warning messages.
This commit is contained in:
2025-10-16 21:58:26 +02:00
parent 7effade1d3
commit bcd52d526c
4 changed files with 144 additions and 1 deletions

View File

@@ -42,6 +42,7 @@ uuid = { workspace = true }
serde_json.workspace = true serde_json.workspace = true
serde.workspace = true serde.workspace = true
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

View File

@@ -0,0 +1,135 @@
use super::{App, messages::AppMessage};
use log::warn;
use owlen_core::{
provider::{GenerateChunk, GenerateRequest, ProviderStatus},
state::AppState,
};
use uuid::Uuid;
/// Trait implemented by UI state containers to react to [`AppMessage`] events.
pub trait MessageState {
/// Called when a generation request is about to start.
#[allow(unused_variables)]
fn start_generation(
&mut self,
request_id: Uuid,
provider_id: &str,
request: &GenerateRequest,
) -> AppState {
AppState::Running
}
/// Called for every streamed generation chunk.
#[allow(unused_variables)]
fn append_chunk(&mut self, request_id: Uuid, chunk: &GenerateChunk) -> AppState {
AppState::Running
}
/// Called when a generation finishes successfully.
#[allow(unused_variables)]
fn generation_complete(&mut self, request_id: Uuid) -> AppState {
AppState::Running
}
/// Called when a generation fails.
#[allow(unused_variables)]
fn generation_failed(&mut self, request_id: Option<Uuid>, message: &str) -> AppState {
AppState::Running
}
/// Called when refreshed model metadata is available.
fn update_model_list(&mut self) -> AppState {
AppState::Running
}
/// Called when a models refresh has been requested.
fn refresh_model_list(&mut self) -> AppState {
AppState::Running
}
/// Called when provider status updates arrive.
#[allow(unused_variables)]
fn update_provider_status(&mut self, provider_id: &str, status: ProviderStatus) -> AppState {
AppState::Running
}
/// Called when a resize event occurs.
#[allow(unused_variables)]
fn handle_resize(&mut self, width: u16, height: u16) -> AppState {
AppState::Running
}
/// Called on periodic ticks.
fn handle_tick(&mut self) -> AppState {
AppState::Running
}
}
impl App {
/// Dispatch a message to the provided [`MessageState`]. Returns `true` when the
/// state indicates the UI should exit.
pub fn handle_message<State>(&mut self, state: &mut State, message: AppMessage) -> bool
where
State: MessageState,
{
use AppMessage::*;
let outcome = match message {
KeyPress(_) => AppState::Running,
Resize { width, height } => state.handle_resize(width, height),
Tick => state.handle_tick(),
GenerateStart {
request_id,
provider_id,
request,
} => state.start_generation(request_id, &provider_id, &request),
GenerateChunk { request_id, chunk } => state.append_chunk(request_id, &chunk),
GenerateComplete { request_id } => {
self.clear_active_generation(request_id);
state.generation_complete(request_id)
}
GenerateError {
request_id,
message,
} => {
self.clear_active_generation_optional(request_id);
state.generation_failed(request_id, &message)
}
ModelsRefresh => state.refresh_model_list(),
ModelsUpdated => state.update_model_list(),
ProviderStatus {
provider_id,
status,
} => state.update_provider_status(&provider_id, status),
};
matches!(outcome, AppState::Quit)
}
fn clear_active_generation(&mut self, request_id: Uuid) {
if self
.active_generation
.as_ref()
.map(|active| active.request_id() == request_id)
.unwrap_or(false)
{
self.active_generation = None;
} else {
warn!(
"received completion for unknown request {}, ignoring",
request_id
);
}
}
fn clear_active_generation_optional(&mut self, request_id: Option<Uuid>) {
match request_id {
Some(id) => self.clear_active_generation(id),
None => {
if self.active_generation.is_some() {
self.active_generation = None;
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
mod generation; mod generation;
mod handler;
mod worker; mod worker;
pub mod messages; pub mod messages;
@@ -12,6 +13,7 @@ use tokio::{
}; };
use uuid::Uuid; use uuid::Uuid;
pub use handler::MessageState;
pub use messages::AppMessage; pub use messages::AppMessage;
/// High-level application state driving the non-blocking TUI. /// High-level application state driving the non-blocking TUI.
@@ -65,7 +67,6 @@ impl App {
} }
struct ActiveGeneration { struct ActiveGeneration {
#[allow(dead_code)]
request_id: Uuid, request_id: Uuid,
#[allow(dead_code)] #[allow(dead_code)]
provider_id: String, provider_id: String,
@@ -88,4 +89,8 @@ impl ActiveGeneration {
fn abort(self) { fn abort(self) {
self.abort_handle.abort(); self.abort_handle.abort();
} }
fn request_id(&self) -> Uuid {
self.request_id
}
} }

View File

@@ -11200,3 +11200,5 @@ fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
textarea.set_cursor_style(Style::default()); textarea.set_cursor_style(Style::default());
textarea.set_cursor_line_style(Style::default()); textarea.set_cursor_line_style(Style::default());
} }
impl crate::app::MessageState for ChatApp {}