feat(tui): introduce MVU core (AppModel, AppEvent, update())

This commit is contained in:
2025-10-17 01:40:50 +02:00
parent a50099ad74
commit 5182f86133
3 changed files with 222 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
mod generation;
mod handler;
pub mod mvu;
mod worker;
pub mod messages;

View File

@@ -0,0 +1,153 @@
use owlen_core::ui::InputMode;
#[derive(Debug, Clone, Default)]
pub struct AppModel {
pub composer: ComposerModel,
}
#[derive(Debug, Clone)]
pub struct ComposerModel {
pub draft: String,
pub pending_submit: bool,
pub mode: InputMode,
}
impl Default for ComposerModel {
fn default() -> Self {
Self {
draft: String::new(),
pending_submit: false,
mode: InputMode::Normal,
}
}
}
#[derive(Debug, Clone)]
pub enum AppEvent {
Composer(ComposerEvent),
}
#[derive(Debug, Clone)]
pub enum ComposerEvent {
DraftChanged { content: String },
ModeChanged { mode: InputMode },
Submit,
SubmissionHandled { result: SubmissionOutcome },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmissionOutcome {
MessageSent,
CommandExecuted,
Failed,
}
#[derive(Debug, Clone)]
pub enum AppEffect {
SetStatus(String),
RequestSubmit,
}
pub fn update(model: &mut AppModel, event: AppEvent) -> Vec<AppEffect> {
match event {
AppEvent::Composer(event) => update_composer(&mut model.composer, event),
}
}
fn update_composer(model: &mut ComposerModel, event: ComposerEvent) -> Vec<AppEffect> {
match event {
ComposerEvent::DraftChanged { content } => {
model.draft = content;
Vec::new()
}
ComposerEvent::ModeChanged { mode } => {
model.mode = mode;
Vec::new()
}
ComposerEvent::Submit => {
if model.draft.trim().is_empty() {
return vec![AppEffect::SetStatus(
"Cannot send empty message".to_string(),
)];
}
model.pending_submit = true;
vec![AppEffect::RequestSubmit]
}
ComposerEvent::SubmissionHandled { result } => {
model.pending_submit = false;
match result {
SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => {
model.draft.clear();
if model.mode == InputMode::Editing {
model.mode = InputMode::Normal;
}
}
SubmissionOutcome::Failed => {}
}
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn submit_with_empty_draft_sets_error() {
let mut model = AppModel::default();
let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
assert!(!model.composer.pending_submit);
assert_eq!(effects.len(), 1);
match &effects[0] {
AppEffect::SetStatus(message) => {
assert!(message.contains("Cannot send empty message"));
}
other => panic!("unexpected effect: {:?}", other),
}
}
#[test]
fn submit_with_content_requests_processing() {
let mut model = AppModel::default();
let _ = update(
&mut model,
AppEvent::Composer(ComposerEvent::DraftChanged {
content: "hello world".into(),
}),
);
let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
assert!(model.composer.pending_submit);
assert_eq!(effects.len(), 1);
matches!(effects[0], AppEffect::RequestSubmit);
}
#[test]
fn submission_success_clears_draft_and_mode() {
let mut model = AppModel::default();
let _ = update(
&mut model,
AppEvent::Composer(ComposerEvent::DraftChanged {
content: "hello world".into(),
}),
);
let _ = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
assert!(model.composer.pending_submit);
let effects = update(
&mut model,
AppEvent::Composer(ComposerEvent::SubmissionHandled {
result: SubmissionOutcome::MessageSent,
}),
);
assert!(effects.is_empty());
assert!(!model.composer.pending_submit);
assert!(model.composer.draft.is_empty());
assert_eq!(model.composer.mode, InputMode::Normal);
}
}

View File

@@ -33,7 +33,10 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use uuid::Uuid;
use crate::app::{MessageState, UiRuntime};
use crate::app::{
MessageState, UiRuntime,
mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome},
};
use crate::config;
use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel;
@@ -308,7 +311,8 @@ pub struct ChatApp {
streaming: HashSet<Uuid>,
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
textarea: TextArea<'static>, // Advanced text input widget
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
mvu_model: AppModel,
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
loading_animation_frame: usize, // Frame counter for loading animation
is_loading: bool, // Whether we're currently loading a response
@@ -555,6 +559,7 @@ impl ChatApp {
streaming: std::collections::HashSet::new(),
stream_tasks: HashMap::new(),
textarea,
mvu_model: AppModel::default(),
pending_llm_request: false,
pending_tool_execution: None,
loading_animation_frame: 0,
@@ -615,6 +620,9 @@ impl ChatApp {
show_message_timestamps: show_timestamps,
};
app.mvu_model.composer.mode = InputMode::Normal;
app.mvu_model.composer.draft = app.controller.input_buffer().text().to_string();
app.append_system_status(&format!(
"Icons: {} ({})",
app.file_icons.status_label(),
@@ -1486,6 +1494,7 @@ impl ChatApp {
self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240));
}
self.mode = mode;
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
}
pub fn mode_flash_active(&self) -> bool {
@@ -2787,7 +2796,10 @@ impl ChatApp {
/// Sync textarea content to input buffer
fn sync_textarea_to_buffer(&mut self) {
let text = self.textarea.lines().join("\n");
self.input_buffer_mut().set_text(text);
self.input_buffer_mut().set_text(text.clone());
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged {
content: text,
}));
}
/// Sync input buffer content to textarea
@@ -2796,6 +2808,55 @@ impl ChatApp {
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
self.textarea = TextArea::new(lines);
configure_textarea_defaults(&mut self.textarea);
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged {
content: text,
}));
}
fn apply_app_event(&mut self, event: AppEvent) -> Vec<AppEffect> {
mvu::update(&mut self.mvu_model, event)
}
async fn handle_app_effects(&mut self, effects: Vec<AppEffect>) -> Result<()> {
let mut pending = effects;
while let Some(effect) = pending.pop() {
match effect {
AppEffect::SetStatus(message) => {
self.error = Some(message.clone());
self.status = message;
}
AppEffect::RequestSubmit => {
let outcome = self.process_composer_submission().await?;
let mut follow_up = self.apply_app_event(AppEvent::Composer(
ComposerEvent::SubmissionHandled { result: outcome },
));
pending.append(&mut follow_up);
match outcome {
SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => {
self.sync_buffer_to_textarea();
self.set_input_mode(InputMode::Normal);
}
SubmissionOutcome::Failed => {
self.sync_buffer_to_textarea();
}
}
}
}
}
Ok(())
}
async fn process_composer_submission(&mut self) -> Result<SubmissionOutcome> {
match self.process_slash_submission().await? {
SlashOutcome::NotCommand => {
self.send_user_message_and_request_response();
Ok(SubmissionOutcome::MessageSent)
}
SlashOutcome::Consumed => Ok(SubmissionOutcome::CommandExecuted),
SlashOutcome::Error => Ok(SubmissionOutcome::Failed),
}
}
pub fn adjust_vertical_split(&mut self, delta: f32) {
@@ -5422,23 +5483,10 @@ impl ChatApp {
}
(KeyCode::Enter, KeyModifiers::NONE) => {
self.sync_textarea_to_buffer();
match self.process_slash_submission().await? {
SlashOutcome::NotCommand => {
self.send_user_message_and_request_response();
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.set_input_mode(InputMode::Normal);
}
SlashOutcome::Consumed => {
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.set_input_mode(InputMode::Normal);
}
SlashOutcome::Error => {
// Restore textarea content so the user can correct the command
self.sync_buffer_to_textarea();
}
}
let effects =
self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
self.handle_app_effects(effects).await?;
return Ok(AppState::Running);
}
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea