diff --git a/crates/owlen-tui/src/app/mod.rs b/crates/owlen-tui/src/app/mod.rs index f20b486..a46376d 100644 --- a/crates/owlen-tui/src/app/mod.rs +++ b/crates/owlen-tui/src/app/mod.rs @@ -1,5 +1,6 @@ mod generation; mod handler; +pub mod mvu; mod worker; pub mod messages; diff --git a/crates/owlen-tui/src/app/mvu.rs b/crates/owlen-tui/src/app/mvu.rs new file mode 100644 index 0000000..d11b9fb --- /dev/null +++ b/crates/owlen-tui/src/app/mvu.rs @@ -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 { + match event { + AppEvent::Composer(event) => update_composer(&mut model.composer, event), + } +} + +fn update_composer(model: &mut ComposerModel, event: ComposerEvent) -> Vec { + 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); + } +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 8143830..f72e3b5 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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, stream_tasks: HashMap>, 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)>, // 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 = 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 { + mvu::update(&mut self.mvu_model, event) + } + + async fn handle_app_effects(&mut self, effects: Vec) -> 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 { + 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