feat(tui): introduce MVU core (AppModel, AppEvent, update())
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
mod generation;
|
||||
mod handler;
|
||||
pub mod mvu;
|
||||
mod worker;
|
||||
|
||||
pub mod messages;
|
||||
|
||||
153
crates/owlen-tui/src/app/mvu.rs
Normal file
153
crates/owlen-tui/src/app/mvu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +311,7 @@ pub struct ChatApp {
|
||||
streaming: HashSet<Uuid>,
|
||||
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
|
||||
textarea: TextArea<'static>, // Advanced text input widget
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user