feat(app): introduce UiRuntime trait and RuntimeApp run loop, add crossterm event conversion, refactor CLI to use RuntimeApp for unified UI handling
This commit is contained in:
@@ -16,19 +16,19 @@ use owlen_core::{
|
|||||||
config::{Config, McpMode},
|
config::{Config, McpMode},
|
||||||
mcp::remote_client::RemoteMcpClient,
|
mcp::remote_client::RemoteMcpClient,
|
||||||
mode::Mode,
|
mode::Mode,
|
||||||
|
provider::ProviderManager,
|
||||||
providers::OllamaProvider,
|
providers::OllamaProvider,
|
||||||
session::SessionController,
|
session::SessionController,
|
||||||
storage::StorageManager,
|
storage::StorageManager,
|
||||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||||
};
|
};
|
||||||
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||||
use owlen_tui::{AppState, ChatApp, Event, EventHandler, SessionEvent, config, ui};
|
use owlen_tui::{ChatApp, SessionEvent, app::App as RuntimeApp, config, ui};
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
||||||
@@ -407,6 +407,8 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let controller =
|
let controller =
|
||||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||||
|
let provider_manager = Arc::new(ProviderManager::default());
|
||||||
|
let mut runtime = RuntimeApp::new(provider_manager);
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||||
app.initialize_models().await?;
|
app.initialize_models().await?;
|
||||||
if let Some(notice) = offline_notice {
|
if let Some(notice) = offline_notice {
|
||||||
@@ -417,12 +419,6 @@ async fn main() -> Result<()> {
|
|||||||
// Set the initial mode
|
// Set the initial mode
|
||||||
app.set_mode(initial_mode).await;
|
app.set_mode(initial_mode).await;
|
||||||
|
|
||||||
// Event infrastructure
|
|
||||||
let cancellation_token = CancellationToken::new();
|
|
||||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
|
||||||
let event_handler = EventHandler::new(event_tx, cancellation_token.clone());
|
|
||||||
let event_handle = tokio::spawn(async move { event_handler.run().await });
|
|
||||||
|
|
||||||
// Terminal setup
|
// Terminal setup
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@@ -435,11 +431,7 @@ async fn main() -> Result<()> {
|
|||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let result = run_app(&mut terminal, &mut app, event_rx, &mut session_rx).await;
|
let result = run_app(&mut terminal, &mut runtime, &mut app, &mut session_rx).await;
|
||||||
|
|
||||||
// Shutdown
|
|
||||||
cancellation_token.cancel();
|
|
||||||
event_handle.await?;
|
|
||||||
|
|
||||||
// Persist configuration updates (e.g., selected model)
|
// Persist configuration updates (e.g., selected model)
|
||||||
config::save_config(&app.config())?;
|
config::save_config(&app.config())?;
|
||||||
@@ -462,58 +454,17 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
async fn run_app(
|
async fn run_app(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
runtime: &mut RuntimeApp,
|
||||||
app: &mut ChatApp,
|
app: &mut ChatApp,
|
||||||
mut event_rx: mpsc::UnboundedReceiver<Event>,
|
|
||||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let stream_draw_interval = tokio::time::Duration::from_millis(50);
|
let mut render = |terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
let idle_tick = tokio::time::Duration::from_millis(100);
|
state: &mut ChatApp|
|
||||||
let mut last_draw = tokio::time::Instant::now() - stream_draw_interval;
|
-> Result<()> {
|
||||||
|
terminal.draw(|f| ui::render_chat(f, state))?;
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
runtime.run(terminal, app, session_rx, &mut render).await?;
|
||||||
// Advance loading animation frame
|
Ok(())
|
||||||
app.advance_loading_animation();
|
|
||||||
|
|
||||||
let streaming_active = app.streaming_count() > 0;
|
|
||||||
let draw_due = if streaming_active {
|
|
||||||
last_draw.elapsed() >= stream_draw_interval
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
};
|
|
||||||
|
|
||||||
if draw_due {
|
|
||||||
terminal.draw(|f| ui::render_chat(f, app))?;
|
|
||||||
last_draw = tokio::time::Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process any pending LLM requests AFTER UI has been drawn
|
|
||||||
if let Err(e) = app.process_pending_llm_request().await {
|
|
||||||
eprintln!("Error processing LLM request: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process any pending tool executions AFTER UI has been drawn
|
|
||||||
if let Err(e) = app.process_pending_tool_execution().await {
|
|
||||||
eprintln!("Error processing tool execution: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sleep_duration = if streaming_active {
|
|
||||||
stream_draw_interval
|
|
||||||
.checked_sub(last_draw.elapsed())
|
|
||||||
.unwrap_or_else(|| tokio::time::Duration::from_millis(0))
|
|
||||||
} else {
|
|
||||||
idle_tick
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
Some(event) = event_rx.recv() => {
|
|
||||||
if let AppState::Quit = app.handle_event(event).await? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(session_event) = session_rx.recv() => {
|
|
||||||
app.handle_session_event(session_event).await?;
|
|
||||||
}
|
|
||||||
_ = tokio::time::sleep(sleep_duration) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,43 @@ mod worker;
|
|||||||
|
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use owlen_core::provider::ProviderManager;
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crossterm::event::{self, KeyEventKind};
|
||||||
|
use owlen_core::{provider::ProviderManager, state::AppState};
|
||||||
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::mpsc,
|
sync::mpsc::{self, error::TryRecvError},
|
||||||
task::{AbortHandle, JoinHandle},
|
task::{AbortHandle, JoinHandle, yield_now},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Event, SessionEvent, events};
|
||||||
|
|
||||||
pub use handler::MessageState;
|
pub use handler::MessageState;
|
||||||
pub use messages::AppMessage;
|
pub use messages::AppMessage;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UiRuntime: MessageState {
|
||||||
|
async fn handle_ui_event(&mut self, event: Event) -> Result<AppState>;
|
||||||
|
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>;
|
||||||
|
async fn process_pending_llm_request(&mut self) -> Result<()>;
|
||||||
|
async fn process_pending_tool_execution(&mut self) -> Result<()>;
|
||||||
|
fn advance_loading_animation(&mut self);
|
||||||
|
fn streaming_count(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
/// High-level application state driving the non-blocking TUI.
|
/// High-level application state driving the non-blocking TUI.
|
||||||
pub struct App {
|
pub struct App {
|
||||||
provider_manager: Arc<ProviderManager>,
|
provider_manager: Arc<ProviderManager>,
|
||||||
message_tx: mpsc::UnboundedSender<AppMessage>,
|
message_tx: mpsc::UnboundedSender<AppMessage>,
|
||||||
#[allow(dead_code)]
|
message_rx: Option<mpsc::UnboundedReceiver<AppMessage>>,
|
||||||
message_rx: mpsc::UnboundedReceiver<AppMessage>,
|
|
||||||
active_generation: Option<ActiveGeneration>,
|
active_generation: Option<ActiveGeneration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +52,7 @@ impl App {
|
|||||||
Self {
|
Self {
|
||||||
provider_manager,
|
provider_manager,
|
||||||
message_tx,
|
message_tx,
|
||||||
message_rx,
|
message_rx: Some(message_rx),
|
||||||
active_generation: None,
|
active_generation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +83,127 @@ impl App {
|
|||||||
worker::background_worker(manager, sender).await;
|
worker::background_worker(manager, sender).await;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive the main UI loop, handling terminal events, background messages, and
|
||||||
|
/// provider status updates without blocking rendering.
|
||||||
|
pub async fn run<State, RenderFn>(
|
||||||
|
&mut self,
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
state: &mut State,
|
||||||
|
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||||
|
mut render: RenderFn,
|
||||||
|
) -> Result<AppState>
|
||||||
|
where
|
||||||
|
State: UiRuntime,
|
||||||
|
RenderFn: FnMut(&mut Terminal<CrosstermBackend<io::Stdout>>, &mut State) -> Result<()>,
|
||||||
|
{
|
||||||
|
let mut message_rx = self
|
||||||
|
.message_rx
|
||||||
|
.take()
|
||||||
|
.expect("App::run called without an available message receiver");
|
||||||
|
|
||||||
|
let poll_interval = Duration::from_millis(16);
|
||||||
|
let mut last_frame = Instant::now();
|
||||||
|
let frame_interval = Duration::from_millis(16);
|
||||||
|
|
||||||
|
let mut worker_handle = Some(self.spawn_background_worker());
|
||||||
|
|
||||||
|
let exit_state = AppState::Quit;
|
||||||
|
'main: loop {
|
||||||
|
state.advance_loading_animation();
|
||||||
|
|
||||||
|
state.process_pending_llm_request().await?;
|
||||||
|
state.process_pending_tool_execution().await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match session_rx.try_recv() {
|
||||||
|
Ok(session_event) => {
|
||||||
|
state.handle_session_event(session_event).await?;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
break 'main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match message_rx.try_recv() {
|
||||||
|
Ok(message) => {
|
||||||
|
if self.handle_message(state, message) {
|
||||||
|
if let Some(handle) = worker_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
break 'main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
||||||
|
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_frame.elapsed() >= frame_interval {
|
||||||
|
render(terminal, state)?;
|
||||||
|
last_frame = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.handle_message(state, AppMessage::Tick) {
|
||||||
|
if let Some(handle) = worker_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
break 'main;
|
||||||
|
}
|
||||||
|
|
||||||
|
match event::poll(poll_interval) {
|
||||||
|
Ok(true) => match event::read() {
|
||||||
|
Ok(raw_event) => {
|
||||||
|
if let Some(ui_event) = events::from_crossterm_event(raw_event) {
|
||||||
|
if let Event::Key(key) = &ui_event {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
let _ = self.message_tx.send(AppMessage::KeyPress(*key));
|
||||||
|
}
|
||||||
|
} else if let Event::Resize(width, height) = &ui_event {
|
||||||
|
let _ = self.message_tx.send(AppMessage::Resize {
|
||||||
|
width: *width,
|
||||||
|
height: *height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(state.handle_ui_event(ui_event).await?, AppState::Quit) {
|
||||||
|
if let Some(handle) = worker_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
break 'main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(handle) = worker_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(handle) = worker_handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield_now().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = worker_handle {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.message_rx = Some(message_rx);
|
||||||
|
|
||||||
|
Ok(exit_state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ActiveGeneration {
|
struct ActiveGeneration {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Local, Utc};
|
use chrono::{DateTime, Local, Utc};
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
@@ -32,6 +33,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app::{MessageState, UiRuntime};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::events::Event;
|
use crate::events::Event;
|
||||||
use crate::model_info_panel::ModelInfoPanel;
|
use crate::model_info_panel::ModelInfoPanel;
|
||||||
@@ -11201,4 +11203,31 @@ fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
|
|||||||
textarea.set_cursor_line_style(Style::default());
|
textarea.set_cursor_line_style(Style::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::app::MessageState for ChatApp {}
|
impl MessageState for ChatApp {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UiRuntime for ChatApp {
|
||||||
|
async fn handle_ui_event(&mut self, event: Event) -> Result<AppState> {
|
||||||
|
ChatApp::handle_event(self, event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
|
||||||
|
ChatApp::handle_session_event(self, event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_pending_llm_request(&mut self) -> Result<()> {
|
||||||
|
ChatApp::process_pending_llm_request(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_pending_tool_execution(&mut self) -> Result<()> {
|
||||||
|
ChatApp::process_pending_tool_execution(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_loading_animation(&mut self) {
|
||||||
|
ChatApp::advance_loading_animation(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn streaming_count(&self) -> usize {
|
||||||
|
ChatApp::streaming_count(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ pub enum Event {
|
|||||||
Tick,
|
Tick,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a raw crossterm event into an application event.
|
||||||
|
pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option<Event> {
|
||||||
|
match raw {
|
||||||
|
crossterm::event::Event::Key(key) => {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
Some(Event::Key(key))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)),
|
||||||
|
crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Event handler that captures terminal events and sends them to the application
|
/// Event handler that captures terminal events and sends them to the application
|
||||||
pub struct EventHandler {
|
pub struct EventHandler {
|
||||||
sender: mpsc::UnboundedSender<Event>,
|
sender: mpsc::UnboundedSender<Event>,
|
||||||
@@ -52,20 +68,8 @@ impl EventHandler {
|
|||||||
if event::poll(timeout).unwrap_or(false) {
|
if event::poll(timeout).unwrap_or(false) {
|
||||||
match event::read() {
|
match event::read() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
match event {
|
if let Some(converted) = from_crossterm_event(event) {
|
||||||
crossterm::event::Event::Key(key) => {
|
let _ = self.sender.send(converted);
|
||||||
// Only handle KeyEventKind::Press to avoid duplicate events
|
|
||||||
if key.kind == KeyEventKind::Press {
|
|
||||||
let _ = self.sender.send(Event::Key(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crossterm::event::Event::Resize(width, height) => {
|
|
||||||
let _ = self.sender.send(Event::Resize(width, height));
|
|
||||||
}
|
|
||||||
crossterm::event::Event::Paste(text) => {
|
|
||||||
let _ = self.sender.send(Event::Paste(text));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user